@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.
@@ -0,0 +1,1790 @@
1
+ // FILE: desktop-ipc-live-owner.js
2
+ // Purpose: Exposes bridge-owned Codex app-server streams to Codex Desktop/VSCode over the local IPC bus.
3
+ // Layer: CLI helper
4
+ // Exports: createDesktopIpcLiveOwner (plus adapter/patch re-exports for compatibility)
5
+ // Depends on: net, ./desktop-ipc-conversation-adapter, ./desktop-ipc-owner-transport, ./desktop-ipc-state-patches, ./desktop-ipc-shared
6
+
7
+ const net = require("net");
8
+
9
+ const {
10
+ CLIENT_STATUS_CHANGED,
11
+ DESKTOP_IPC_METHOD_VERSIONS: METHOD_VERSION_BY_NAME,
12
+ cloneJSON,
13
+ conversationSnapshotShowsActiveTurn,
14
+ isPlainJSONObject,
15
+ normalizeToken,
16
+ readString,
17
+ requestIdKey,
18
+ resolveDefaultIpcSocketPath,
19
+ safeParseJSON,
20
+ visibleUserPromptFromInputEntries,
21
+ } = require("./desktop-ipc-shared");
22
+ const {
23
+ LOCAL_HOST_ID,
24
+ applyAppServerMessageToConversationState,
25
+ buildConversationStateFromThread,
26
+ createEmptyConversationState,
27
+ readThreadIdFromParams,
28
+ } = require("./desktop-ipc-conversation-adapter");
29
+ const {
30
+ DEFAULT_MAX_PATCH_BYTES,
31
+ DEFAULT_MAX_PATCH_COUNT,
32
+ applyPatchesToBaselineState,
33
+ buildConversationStatePatches,
34
+ } = require("./desktop-ipc-state-patches");
35
+ const {
36
+ createDesktopOwnerIpcClient,
37
+ } = require("./desktop-ipc-owner-transport");
38
+
39
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
40
+ const DEFAULT_RECONNECT_MS = 1_500;
41
+ const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 75;
42
+ const DEFAULT_INITIAL_HISTORY_RETRY_MS = 1_000;
43
+ const DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS = 5;
44
+ // Ownership is only authoritative while the Desktop stream keeps updating.
45
+ // A reconnect can otherwise leave a thread in ownedThreadIds indefinitely and
46
+ // suppress the rollout mirror that is carrying the real live activity.
47
+ const DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS = 20_000;
48
+ // Desktop's webview refreshes its recent-conversations list when it receives a
49
+ // thread-unarchived broadcast for its host. Give the rollout writer a moment to
50
+ // persist session_meta + the first user event so the refreshed thread/list scan
51
+ // can actually see the thread.
52
+ const DEFAULT_SIDEBAR_REFRESH_DELAY_MS = 1_200;
53
+ const THREAD_STREAM_STATE_CHANGED = "thread-stream-state-changed";
54
+ // Cached thread/read responses are only a hydration convenience; owned threads
55
+ // are never evicted, so a small cap keeps long browsing sessions bounded.
56
+ const MAX_CACHED_THREADS = 30;
57
+ const THREAD_ARCHIVED = "thread-archived";
58
+ const THREAD_UNARCHIVED = "thread-unarchived";
59
+ const THREAD_READ_STATE_CHANGED = "thread-read-state-changed";
60
+ const THREAD_QUEUED_FOLLOWUPS_CHANGED = "thread-queued-followups-changed";
61
+ const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
62
+
63
+ const SUPPORTED_FOLLOWER_REQUEST_METHODS = new Set([
64
+ "thread-follower-start-turn",
65
+ "thread-follower-load-complete-history",
66
+ "thread-follower-update-thread-settings",
67
+ "thread-follower-compact-thread",
68
+ "thread-follower-steer-turn",
69
+ "thread-follower-interrupt-turn",
70
+ "thread-follower-set-model-and-reasoning",
71
+ "thread-follower-set-collaboration-mode",
72
+ "thread-follower-command-approval-decision",
73
+ "thread-follower-file-approval-decision",
74
+ "thread-follower-permissions-request-approval-response",
75
+ "thread-follower-submit-user-input",
76
+ "thread-follower-submit-mcp-server-elicitation-response",
77
+ "thread-follower-set-queued-follow-ups-state",
78
+ ]);
79
+
80
+ const OWNER_INBOUND_METHODS = new Set([
81
+ "thread/start",
82
+ "turn/start",
83
+ "turn/steer",
84
+ "turn/interrupt",
85
+ "thread/compact/start",
86
+ "thread/archive",
87
+ "thread/unarchive",
88
+ "thread/unsubscribe",
89
+ ]);
90
+
91
+ const THREAD_READ_METHODS = new Set(["thread/read", "thread/resume"]);
92
+
93
+ const ALLOWED_TURN_START_PARAM_KEYS = new Set([
94
+ "threadId",
95
+ "input",
96
+ "cwd",
97
+ "approvalPolicy",
98
+ "approvalsReviewer",
99
+ "sandboxPolicy",
100
+ "model",
101
+ "serviceTier",
102
+ "effort",
103
+ "summary",
104
+ "personality",
105
+ "outputSchema",
106
+ "collaborationMode",
107
+ ]);
108
+
109
+ function createDesktopIpcLiveOwner({
110
+ enabled = true,
111
+ hostId = LOCAL_HOST_ID,
112
+ sendApplicationResponse = null,
113
+ sendCodexRequest,
114
+ sendRawCodexMessage,
115
+ normalizeTurnStartParams = (params) => params,
116
+ runtimeSettingsStore = null,
117
+ socketPath = resolveDefaultIpcSocketPath(),
118
+ sidebarRefreshDelayMs = DEFAULT_SIDEBAR_REFRESH_DELAY_MS,
119
+ snapshotDebounceMs = DEFAULT_SNAPSHOT_DEBOUNCE_MS,
120
+ maxPatchCount = DEFAULT_MAX_PATCH_COUNT,
121
+ maxPatchBytes = DEFAULT_MAX_PATCH_BYTES,
122
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
123
+ reconnectMs = DEFAULT_RECONNECT_MS,
124
+ initialHistoryRetryMs = DEFAULT_INITIAL_HISTORY_RETRY_MS,
125
+ initialHistoryMaxAttempts = DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS,
126
+ liveOwnershipFreshnessMs = DEFAULT_LIVE_OWNERSHIP_FRESHNESS_MS,
127
+ netModule = net,
128
+ now = () => Date.now(),
129
+ logPrefix = "[remodex]",
130
+ } = {}) {
131
+ if (!enabled || typeof sendCodexRequest !== "function" || typeof sendRawCodexMessage !== "function") {
132
+ return createDisabledDesktopIpcLiveOwner();
133
+ }
134
+ const sendPhoneNotification = typeof sendApplicationResponse === "function"
135
+ ? sendApplicationResponse
136
+ : () => {};
137
+
138
+ const conversations = new Map();
139
+ const ownedThreadIds = new Set();
140
+ // Pending local thread/start requests in FIFO order, keyed by request id with
141
+ // the requested cwd so notification-only thread/started events pair correctly.
142
+ const pendingThreadStartRequestIds = new Map();
143
+ const pendingThreadReadRequestIds = new Set();
144
+ const pendingThreadHydrationsByThreadId = new Map();
145
+ // Unknown existing threads need a real history baseline before the first
146
+ // Desktop snapshot; any seeded partial state can replace all desktop rows.
147
+ const threadsAwaitingInitialHistoryByThreadId = new Set();
148
+ const initialHistoryRetryAfterByThreadId = new Map();
149
+ const initialHistoryRetryTimersByThreadId = new Map();
150
+ const initialHistoryAttemptCountByThreadId = new Map();
151
+ const cachedThreadsByThreadId = new Map();
152
+ const lastBroadcastStatesByThreadId = new Map();
153
+ const fallbackTurnIdsByThreadId = new Map();
154
+ // Desktop followers track monotonic stream revisions: patches apply only when
155
+ // baseRevision matches their last-seen revision, and load-complete-history
156
+ // waits for the snapshot carrying the returned revision.
157
+ const streamRevisionsByThreadId = new Map();
158
+ const announcedSidebarThreadIds = new Set();
159
+ const sidebarRefreshTimersByThreadId = new Map();
160
+ const pendingTurnStartParamsByThreadId = new Map();
161
+ const pendingTurnStartEntriesByRequestId = new Map();
162
+ const followerRuntimeOverridesByThreadId = new Map();
163
+ // Desktop delegates queued follow-ups to the stream owner: followers push the
164
+ // whole queue state here and expect the owner to run entries between turns.
165
+ const queuedFollowUpsByThreadId = new Map();
166
+ const runningQueuedFollowUpThreadIds = new Set();
167
+ const announcedReadStateThreadIds = new Set();
168
+ const pendingThreadArchiveMetadataByThreadId = new Map();
169
+ const dirtyThreadIds = new Set();
170
+ let snapshotTimer = null;
171
+ let optimisticTurnSerial = 0;
172
+
173
+ const ipc = createDesktopOwnerIpcClient({
174
+ socketPath,
175
+ netModule,
176
+ now,
177
+ requestTimeoutMs,
178
+ reconnectMs,
179
+ logPrefix,
180
+ onConnected() {
181
+ flushPendingThreadArchiveMetadataBroadcasts();
182
+ broadcastAllOwnedSnapshots();
183
+ },
184
+ onBroadcast(envelope) {
185
+ handlePeerBroadcast(envelope);
186
+ },
187
+ canHandleRequest(envelope) {
188
+ return canHandleFollowerRequest(envelope);
189
+ },
190
+ handleRequest(envelope) {
191
+ return handleFollowerRequest(envelope);
192
+ },
193
+ });
194
+
195
+ function observeInbound(rawMessage, parsedMessage = null) {
196
+ const message = parsedMessage ?? safeParseJSON(rawMessage);
197
+ const method = readString(message?.method);
198
+ if (THREAD_READ_METHODS.has(method)) {
199
+ if (message?.id != null) {
200
+ pendingThreadReadRequestIds.add(String(message.id));
201
+ }
202
+ markThreadReadByPhone(readThreadIdFromParams(message?.params));
203
+ return;
204
+ }
205
+
206
+ if (!method || !OWNER_INBOUND_METHODS.has(method)) {
207
+ return;
208
+ }
209
+
210
+ if (method === "thread/start") {
211
+ if (message?.id != null) {
212
+ pendingThreadStartRequestIds.set(String(message.id), readString(message?.params?.cwd));
213
+ }
214
+ ipc.ensureConnected();
215
+ return;
216
+ }
217
+
218
+ const threadId = readThreadIdFromParams(message?.params);
219
+ if (!threadId) {
220
+ return;
221
+ }
222
+
223
+ if (method === "thread/archive") {
224
+ broadcastThreadArchived(threadId, readArchiveCwd(threadId, message?.params));
225
+ removeOwnedThread(threadId, {
226
+ broadcastRemoval: true,
227
+ reason: method,
228
+ skipArchiveMetadataBroadcast: true,
229
+ });
230
+ return;
231
+ }
232
+ if (method === "thread/unsubscribe") {
233
+ // The phone leaving the screen releases idle threads, but a thread whose
234
+ // local turn is still executing stays owned: dropping it mid-run lets
235
+ // fallback mirrors and Desktop routing corrupt the timeline on reopen.
236
+ if (!hasActiveLocalTurn(threadId)) {
237
+ removeOwnedThread(threadId, { broadcastRemoval: true, reason: method });
238
+ }
239
+ return;
240
+ }
241
+ if (method === "thread/unarchive") {
242
+ broadcastThreadUnarchived(threadId);
243
+ return;
244
+ }
245
+
246
+ const hadConversation = conversations.has(threadId);
247
+ const hadCachedThread = cachedThreadsByThreadId.has(threadId);
248
+ markOwnedThread(threadId);
249
+ if (!hadConversation && !hadCachedThread) {
250
+ threadsAwaitingInitialHistoryByThreadId.add(threadId);
251
+ }
252
+ let pendingTurnStartEntry = null;
253
+ if (method === "turn/start") {
254
+ pendingTurnStartEntry = rememberPendingTurnStart(threadId, message?.params, message?.id);
255
+ scheduleSidebarAnnouncement(threadId);
256
+ }
257
+ if (method === "turn/interrupt") {
258
+ markTurnInterruptedOptimistically(threadId, message?.params);
259
+ }
260
+ seedOwnedConversation(threadId, {
261
+ cwd: readString(message?.params?.cwd),
262
+ });
263
+ if (pendingTurnStartEntry) {
264
+ insertOptimisticPendingTurn(threadId, pendingTurnStartEntry);
265
+ }
266
+ if (!hadConversation && !hadCachedThread) {
267
+ requestInitialHistoryBaselineIfDue(threadId);
268
+ }
269
+ scheduleSnapshot(threadId);
270
+ }
271
+
272
+ function observeOutbound(rawMessage, parsedMessage = null) {
273
+ const message = parsedMessage ?? safeParseJSON(rawMessage);
274
+ if (!message || typeof message !== "object") {
275
+ return;
276
+ }
277
+
278
+ const responseId = message.id == null ? "" : String(message.id);
279
+ if (responseId && !message.method) {
280
+ resolvePendingTurnStartResponse(responseId, message);
281
+ }
282
+ if (responseId && pendingThreadReadRequestIds.has(responseId)) {
283
+ pendingThreadReadRequestIds.delete(responseId);
284
+ const thread = readThreadFromResponse(message);
285
+ if (thread?.id) {
286
+ rememberCachedThread(thread.id, thread);
287
+ if (ownedThreadIds.has(thread.id)) {
288
+ upsertConversationFromThread(thread);
289
+ scheduleSnapshot(thread.id);
290
+ }
291
+ }
292
+ }
293
+
294
+ if (responseId && pendingThreadStartRequestIds.has(responseId)) {
295
+ pendingThreadStartRequestIds.delete(responseId);
296
+ const thread = readThreadFromResponse(message);
297
+ if (thread?.id) {
298
+ markOwnedThread(thread.id);
299
+ upsertConversationFromThread(thread);
300
+ scheduleSnapshot(thread.id);
301
+ }
302
+ }
303
+ claimStartedThreadForPendingLocalStart(message);
304
+
305
+ const update = applyAppServerMessageToConversationState({
306
+ conversations,
307
+ fallbackTurnIdsByThreadId,
308
+ pendingTurnStartParamsByThreadId,
309
+ message,
310
+ hostId,
311
+ now,
312
+ shouldOwnThread(threadId) {
313
+ return ownedThreadIds.has(threadId);
314
+ },
315
+ });
316
+
317
+ if (update?.threadId && update.changed) {
318
+ // thread/started already carries the authoritative thread baseline, including
319
+ // new-thread cases where turn/start reached the bridge first.
320
+ if (readString(message.method) === "thread/started") {
321
+ stopAwaitingInitialHistory(update.threadId);
322
+ }
323
+ refreshOptimisticFallbackForThread(update.threadId);
324
+ scheduleSnapshot(update.threadId);
325
+ }
326
+
327
+ if (readString(message.method) === "turn/completed") {
328
+ const completedThreadId = readThreadIdFromParams(message.params);
329
+ const completedStatus = normalizeToken(message.params?.turn?.status);
330
+ // Mirror Desktop's pause-after-interrupt semantics: a stopped turn means
331
+ // the user wants things halted, so queued follow-ups wait for the next
332
+ // explicit trigger (queue edit or a normally completed turn).
333
+ if (completedThreadId
334
+ && ownedThreadIds.has(completedThreadId)
335
+ && completedStatus !== "interrupted"
336
+ && completedStatus !== "cancelled"
337
+ && completedStatus !== "canceled") {
338
+ runNextQueuedFollowUp(completedThreadId);
339
+ }
340
+ }
341
+ }
342
+
343
+ function stopAll() {
344
+ if (snapshotTimer) {
345
+ clearTimeout(snapshotTimer);
346
+ snapshotTimer = null;
347
+ }
348
+ dirtyThreadIds.clear();
349
+ pendingThreadStartRequestIds.clear();
350
+ pendingThreadReadRequestIds.clear();
351
+ pendingThreadHydrationsByThreadId.clear();
352
+ threadsAwaitingInitialHistoryByThreadId.clear();
353
+ initialHistoryRetryAfterByThreadId.clear();
354
+ initialHistoryAttemptCountByThreadId.clear();
355
+ for (const timer of initialHistoryRetryTimersByThreadId.values()) {
356
+ clearTimeout(timer);
357
+ }
358
+ initialHistoryRetryTimersByThreadId.clear();
359
+ cachedThreadsByThreadId.clear();
360
+ lastBroadcastStatesByThreadId.clear();
361
+ fallbackTurnIdsByThreadId.clear();
362
+ streamRevisionsByThreadId.clear();
363
+ for (const timer of sidebarRefreshTimersByThreadId.values()) {
364
+ clearTimeout(timer);
365
+ }
366
+ sidebarRefreshTimersByThreadId.clear();
367
+ announcedSidebarThreadIds.clear();
368
+ pendingTurnStartParamsByThreadId.clear();
369
+ pendingTurnStartEntriesByRequestId.clear();
370
+ followerRuntimeOverridesByThreadId.clear();
371
+ pendingThreadArchiveMetadataByThreadId.clear();
372
+ queuedFollowUpsByThreadId.clear();
373
+ runningQueuedFollowUpThreadIds.clear();
374
+ announcedReadStateThreadIds.clear();
375
+ ownedThreadIds.clear();
376
+ conversations.clear();
377
+ ipc.close();
378
+ }
379
+
380
+ // Desktop's sidebar tracks unread markers via thread-read-state-changed; tell
381
+ // it when the phone opens an owned thread so badges clear on both devices.
382
+ // Repeated reads of an already-clean thread stay silent to avoid IPC noise.
383
+ function markThreadReadByPhone(threadId) {
384
+ const normalizedThreadId = readString(threadId);
385
+ if (!normalizedThreadId || !ownedThreadIds.has(normalizedThreadId)) {
386
+ return;
387
+ }
388
+ const conversation = conversations.get(normalizedThreadId);
389
+ const hadUnread = Boolean(conversation
390
+ && (conversation.hasUnreadTurn || conversation.unreadMessageCount > 0));
391
+ if (hadUnread) {
392
+ conversation.hasUnreadTurn = false;
393
+ conversation.unreadMessageCount = 0;
394
+ scheduleSnapshot(normalizedThreadId);
395
+ } else if (announcedReadStateThreadIds.has(normalizedThreadId)) {
396
+ return;
397
+ }
398
+ if (ipc.sendBroadcast(THREAD_READ_STATE_CHANGED, {
399
+ conversationId: normalizedThreadId,
400
+ hasUnreadTurn: false,
401
+ })) {
402
+ announcedReadStateThreadIds.add(normalizedThreadId);
403
+ }
404
+ }
405
+
406
+ // Snappier Stop UX on Desktop: flip the active turn to interrupted right away;
407
+ // authoritative app-server events overwrite this if the interrupt fails.
408
+ function markTurnInterruptedOptimistically(threadId, params) {
409
+ const conversation = conversations.get(readString(threadId));
410
+ if (!conversation) {
411
+ return;
412
+ }
413
+ const requestedTurnId = readString(params?.turnId) || readString(params?.turn_id);
414
+ for (let index = conversation.turns.length - 1; index >= 0; index -= 1) {
415
+ const turn = conversation.turns[index];
416
+ const turnId = readString(turn?.turnId) || readString(turn?.id);
417
+ const matchesRequest = requestedTurnId ? turnId === requestedTurnId : true;
418
+ if (matchesRequest && normalizeToken(turn?.status) === "inprogress") {
419
+ turn.status = "interrupted";
420
+ conversation.threadRuntimeStatus = { type: "idle" };
421
+ conversation.updatedAt = now();
422
+ return;
423
+ }
424
+ if (requestedTurnId && turnId === requestedTurnId) {
425
+ return;
426
+ }
427
+ }
428
+ }
429
+
430
+ // Phone-origin prompts only exist in the inbound turn/start params, so cache
431
+ // them for the matching turn/started snapshot instead of losing the user row.
432
+ // Entries are FIFO per thread so rapid consecutive starts keep their own prompt,
433
+ // and failed starts are discarded so stale input never attaches to a later turn.
434
+ function rememberPendingTurnStart(threadId, params, requestId) {
435
+ const normalizedThreadId = readString(threadId);
436
+ if (!normalizedThreadId) {
437
+ return null;
438
+ }
439
+ const normalizedRequestId = requestIdKey(requestId);
440
+ const existingPending = normalizedRequestId ? pendingTurnStartEntriesByRequestId.get(normalizedRequestId) : null;
441
+ if (existingPending?.threadId === normalizedThreadId) {
442
+ return existingPending.entry?.consumed ? null : existingPending.entry;
443
+ }
444
+ const input = Array.isArray(params?.input) ? params.input : [];
445
+ if (input.length === 0) {
446
+ return null;
447
+ }
448
+ const sanitizedParams = sanitizeTurnStartParams(cloneJSON(params));
449
+ sanitizedParams.input = normalizeInputEntriesForDesktop(sanitizedParams.input);
450
+ const entry = { params: sanitizedParams, requestId: normalizedRequestId || null };
451
+ const queue = pendingTurnStartParamsByThreadId.get(normalizedThreadId) || [];
452
+ queue.push(entry);
453
+ pendingTurnStartParamsByThreadId.set(normalizedThreadId, queue);
454
+ if (normalizedRequestId) {
455
+ pendingTurnStartEntriesByRequestId.set(normalizedRequestId, {
456
+ threadId: normalizedThreadId,
457
+ entry,
458
+ });
459
+ }
460
+ return entry;
461
+ }
462
+
463
+ function discardPendingTurnStartEntry(threadId, entry) {
464
+ const normalizedThreadId = readString(threadId);
465
+ if (!normalizedThreadId || !entry) {
466
+ return;
467
+ }
468
+ const didRemoveOptimisticTurn = removeOptimisticPendingTurn(normalizedThreadId, entry);
469
+ const queue = pendingTurnStartParamsByThreadId.get(normalizedThreadId);
470
+ if (!queue) {
471
+ if (didRemoveOptimisticTurn) {
472
+ scheduleSnapshot(normalizedThreadId);
473
+ }
474
+ return;
475
+ }
476
+ const index = queue.indexOf(entry);
477
+ if (index >= 0) {
478
+ queue.splice(index, 1);
479
+ }
480
+ if (queue.length === 0) {
481
+ pendingTurnStartParamsByThreadId.delete(normalizedThreadId);
482
+ }
483
+ refreshOptimisticFallbackForThread(normalizedThreadId);
484
+ if (didRemoveOptimisticTurn) {
485
+ scheduleSnapshot(normalizedThreadId);
486
+ }
487
+ }
488
+
489
+ function resolvePendingTurnStartResponse(responseId, message) {
490
+ const pending = pendingTurnStartEntriesByRequestId.get(responseId);
491
+ if (!pending) {
492
+ return;
493
+ }
494
+ pendingTurnStartEntriesByRequestId.delete(responseId);
495
+ if (message.error) {
496
+ discardPendingTurnStartEntry(pending.threadId, pending.entry);
497
+ const remainingStarts = pendingTurnStartParamsByThreadId.get(pending.threadId) || [];
498
+ if (remainingStarts.length === 0
499
+ && threadsAwaitingInitialHistoryByThreadId.has(pending.threadId)) {
500
+ // A rejected first turn cannot produce a usable baseline. Relinquish
501
+ // ownership instead of polling a thread that may never have existed.
502
+ removeOwnedThread(pending.threadId);
503
+ }
504
+ return;
505
+ }
506
+ commitAcceptedRuntimeSettings(
507
+ pending.threadId,
508
+ pending.entry?.params,
509
+ "phone",
510
+ readTurnIdFromResult(message.result)
511
+ );
512
+ scheduleSnapshot(pending.threadId);
513
+ }
514
+
515
+ // Publishes phone-origin turns as soon as the bridge sees turn/start, instead
516
+ // of waiting for image-heavy turn/started events to come back from the runtime.
517
+ function insertOptimisticPendingTurn(threadId, entry) {
518
+ const normalizedThreadId = readString(threadId);
519
+ const params = entry?.params;
520
+ const input = Array.isArray(params?.input) ? params.input : [];
521
+ if (!normalizedThreadId || entry?.consumed || !params || input.length === 0) {
522
+ return null;
523
+ }
524
+
525
+ const conversation = ensureConversation(normalizedThreadId, {
526
+ cwd: readString(params.cwd),
527
+ });
528
+ if (!conversation) {
529
+ return null;
530
+ }
531
+
532
+ const optimisticTurnId = ensureOptimisticTurnId(normalizedThreadId, entry);
533
+ if (!readString(fallbackTurnIdsByThreadId.get(normalizedThreadId))) {
534
+ fallbackTurnIdsByThreadId.set(normalizedThreadId, optimisticTurnId);
535
+ }
536
+ if (conversation.turns.some((turn) => (
537
+ (readString(turn?.turnId) || readString(turn?.id)) === optimisticTurnId
538
+ ))) {
539
+ return optimisticTurnId;
540
+ }
541
+
542
+ const timestamp = now();
543
+ const turnParams = cloneJSON(params);
544
+ turnParams.threadId = normalizedThreadId;
545
+ turnParams.cwd = readString(params.cwd) || conversation.cwd || null;
546
+
547
+ conversation.turns.push({
548
+ id: optimisticTurnId,
549
+ turnId: optimisticTurnId,
550
+ params: turnParams,
551
+ turnStartedAtMs: timestamp,
552
+ durationMs: null,
553
+ firstTurnWorkItemStartedAtMs: null,
554
+ finalAssistantStartedAtMs: null,
555
+ status: "inProgress",
556
+ error: null,
557
+ diff: null,
558
+ hookRuns: [],
559
+ commandExecutionStartedAtMsById: {},
560
+ items: [],
561
+ remodexOptimisticPendingTurn: true,
562
+ });
563
+ conversation.hasUnreadTurn = true;
564
+ conversation.updatedAt = timestamp;
565
+ return optimisticTurnId;
566
+ }
567
+
568
+ function refreshOptimisticFallbackForThread(threadId) {
569
+ const normalizedThreadId = readString(threadId);
570
+ if (!normalizedThreadId || readString(fallbackTurnIdsByThreadId.get(normalizedThreadId))) {
571
+ return;
572
+ }
573
+ const queue = pendingTurnStartParamsByThreadId.get(normalizedThreadId);
574
+ const nextEntry = Array.isArray(queue)
575
+ ? queue.find((entry) => readString(entry?.optimisticTurnId))
576
+ : null;
577
+ const nextOptimisticTurnId = readString(nextEntry?.optimisticTurnId);
578
+ if (nextOptimisticTurnId) {
579
+ fallbackTurnIdsByThreadId.set(normalizedThreadId, nextOptimisticTurnId);
580
+ }
581
+ }
582
+
583
+ function ensureOptimisticTurnId(threadId, entry) {
584
+ if (entry.optimisticTurnId) {
585
+ return entry.optimisticTurnId;
586
+ }
587
+ optimisticTurnSerial += 1;
588
+ const requestSegment = readString(entry.requestId) || `local-${optimisticTurnSerial}`;
589
+ entry.optimisticTurnId = `remodex-pending-turn:${threadId}:${requestSegment}`;
590
+ return entry.optimisticTurnId;
591
+ }
592
+
593
+ function removeOptimisticPendingTurn(threadId, entry) {
594
+ const optimisticTurnId = readString(entry?.optimisticTurnId);
595
+ const conversation = optimisticTurnId ? conversations.get(threadId) : null;
596
+ if (!conversation || !Array.isArray(conversation.turns)) {
597
+ return false;
598
+ }
599
+ const index = conversation.turns.findIndex((turn) => (
600
+ turn?.remodexOptimisticPendingTurn
601
+ && (readString(turn.turnId) || readString(turn.id)) === optimisticTurnId
602
+ ));
603
+ if (index < 0) {
604
+ return false;
605
+ }
606
+ conversation.turns.splice(index, 1);
607
+ if (readString(fallbackTurnIdsByThreadId.get(threadId)) === optimisticTurnId) {
608
+ fallbackTurnIdsByThreadId.delete(threadId);
609
+ }
610
+ conversation.updatedAt = now();
611
+ return true;
612
+ }
613
+
614
+ function markOwnedThread(threadId) {
615
+ const normalizedThreadId = readString(threadId);
616
+ if (!normalizedThreadId) {
617
+ return;
618
+ }
619
+ ownedThreadIds.add(normalizedThreadId);
620
+ ipc.ensureConnected();
621
+ }
622
+
623
+ // Notification-only thread/start paths do not echo a request id, so consume the
624
+ // oldest pending local start whose cwd matches the started thread. Requiring a
625
+ // cwd match keeps overlapping starts from claiming threads created elsewhere.
626
+ function claimStartedThreadForPendingLocalStart(message) {
627
+ if (readString(message?.method) !== "thread/started" || pendingThreadStartRequestIds.size === 0) {
628
+ return;
629
+ }
630
+ const thread = message?.params?.thread;
631
+ const threadId = readString(thread?.id);
632
+ if (!threadId || ownedThreadIds.has(threadId)) {
633
+ return;
634
+ }
635
+ const threadCwd = readString(thread?.cwd);
636
+ for (const [pendingRequestId, pendingCwd] of pendingThreadStartRequestIds) {
637
+ if (pendingCwd && threadCwd && pendingCwd !== threadCwd) {
638
+ continue;
639
+ }
640
+ pendingThreadStartRequestIds.delete(pendingRequestId);
641
+ markOwnedThread(threadId);
642
+ return;
643
+ }
644
+ }
645
+
646
+ function removeOwnedThread(threadId, { broadcastRemoval = false, reason = "", skipArchiveMetadataBroadcast = false } = {}) {
647
+ const normalizedThreadId = readString(threadId);
648
+ if (!normalizedThreadId) {
649
+ return;
650
+ }
651
+ if (broadcastRemoval && ownedThreadIds.has(normalizedThreadId)) {
652
+ broadcastRemovedConversationState(normalizedThreadId, {
653
+ reason,
654
+ skipArchiveMetadataBroadcast,
655
+ });
656
+ }
657
+ ownedThreadIds.delete(normalizedThreadId);
658
+ conversations.delete(normalizedThreadId);
659
+ cachedThreadsByThreadId.delete(normalizedThreadId);
660
+ pendingThreadHydrationsByThreadId.delete(normalizedThreadId);
661
+ stopAwaitingInitialHistory(normalizedThreadId);
662
+ lastBroadcastStatesByThreadId.delete(normalizedThreadId);
663
+ fallbackTurnIdsByThreadId.delete(normalizedThreadId);
664
+ streamRevisionsByThreadId.delete(normalizedThreadId);
665
+ // An active-peer takeover can still drop a non-empty queue (hasActiveLocalTurn
666
+ // only shields idle yields); announce the emptied queue instead of letting
667
+ // clients keep rendering drafts the bridge will never run.
668
+ const droppedQueuedFollowUps = (queuedFollowUpsByThreadId.get(normalizedThreadId) || []).length > 0;
669
+ queuedFollowUpsByThreadId.delete(normalizedThreadId);
670
+ if (droppedQueuedFollowUps) {
671
+ broadcastQueuedFollowUps(normalizedThreadId);
672
+ }
673
+ runningQueuedFollowUpThreadIds.delete(normalizedThreadId);
674
+ announcedReadStateThreadIds.delete(normalizedThreadId);
675
+ cancelSidebarAnnouncement(normalizedThreadId);
676
+ announcedSidebarThreadIds.delete(normalizedThreadId);
677
+ pendingTurnStartParamsByThreadId.delete(normalizedThreadId);
678
+ followerRuntimeOverridesByThreadId.delete(normalizedThreadId);
679
+ for (const [requestId, pending] of Array.from(pendingTurnStartEntriesByRequestId.entries())) {
680
+ if (pending.threadId === normalizedThreadId) {
681
+ pendingTurnStartEntriesByRequestId.delete(requestId);
682
+ }
683
+ }
684
+ dirtyThreadIds.delete(normalizedThreadId);
685
+ }
686
+
687
+ function broadcastRemovedConversationState(threadId, { reason = "", skipArchiveMetadataBroadcast = false } = {}) {
688
+ const previousState = conversations.get(threadId)
689
+ || lastBroadcastStatesByThreadId.get(threadId)
690
+ || createEmptyConversationState(threadId, { hostId, now });
691
+ if (reason === "thread/archive" && !skipArchiveMetadataBroadcast) {
692
+ broadcastThreadArchived(threadId, readString(previousState?.cwd));
693
+ }
694
+ const removedState = {
695
+ ...cloneJSON(previousState),
696
+ id: threadId,
697
+ hostId,
698
+ turns: [],
699
+ requests: [],
700
+ hasUnreadTurn: false,
701
+ unreadMessageCount: 0,
702
+ updatedAt: now(),
703
+ remodexRemoved: true,
704
+ remodexRemovalReason: reason || null,
705
+ archived: reason === "thread/archive" || Boolean(previousState?.archived),
706
+ unsubscribed: reason === "thread/unsubscribe" || Boolean(previousState?.unsubscribed),
707
+ };
708
+ ipc.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
709
+ conversationId: threadId,
710
+ version: METHOD_VERSION_BY_NAME.get(THREAD_STREAM_STATE_CHANGED) || 1,
711
+ remodexOwnerSource: REMODEX_LIVE_OWNER_SOURCE,
712
+ remodexOwnerReleased: true,
713
+ change: {
714
+ type: "snapshot",
715
+ conversationState: removedState,
716
+ },
717
+ });
718
+ }
719
+
720
+ function broadcastThreadArchived(threadId, cwd) {
721
+ queueThreadArchiveMetadataBroadcast(THREAD_ARCHIVED, threadId, { cwd });
722
+ }
723
+
724
+ function broadcastThreadUnarchived(threadId) {
725
+ queueThreadArchiveMetadataBroadcast(THREAD_UNARCHIVED, threadId);
726
+ }
727
+
728
+ // Desktop has no watcher on the shared session store, but its webview reacts
729
+ // to thread-unarchived broadcasts by re-running thread/list. Announcing each
730
+ // phone-driven thread once (after the rollout has had time to persist) makes
731
+ // it appear in Desktop's sidebar without the disruptive deep-link bounce.
732
+ function scheduleSidebarAnnouncement(threadId) {
733
+ const normalizedThreadId = readString(threadId);
734
+ if (!normalizedThreadId
735
+ || announcedSidebarThreadIds.has(normalizedThreadId)
736
+ || sidebarRefreshTimersByThreadId.has(normalizedThreadId)) {
737
+ return;
738
+ }
739
+ const timer = setTimeout(() => {
740
+ sidebarRefreshTimersByThreadId.delete(normalizedThreadId);
741
+ if (!ownedThreadIds.has(normalizedThreadId)) {
742
+ return;
743
+ }
744
+ announcedSidebarThreadIds.add(normalizedThreadId);
745
+ broadcastThreadUnarchived(normalizedThreadId);
746
+ }, Math.max(0, sidebarRefreshDelayMs));
747
+ timer.unref?.();
748
+ sidebarRefreshTimersByThreadId.set(normalizedThreadId, timer);
749
+ }
750
+
751
+ function cancelSidebarAnnouncement(threadId) {
752
+ const timer = sidebarRefreshTimersByThreadId.get(threadId);
753
+ if (timer) {
754
+ clearTimeout(timer);
755
+ sidebarRefreshTimersByThreadId.delete(threadId);
756
+ }
757
+ }
758
+
759
+ // Archive/unarchive are list metadata updates; keep only the final per-thread
760
+ // state while IPC reconnects so rapid toggles cannot flush out of order.
761
+ function queueThreadArchiveMetadataBroadcast(method, threadId, { cwd } = {}) {
762
+ const normalizedThreadId = readString(threadId);
763
+ if (!normalizedThreadId) {
764
+ return;
765
+ }
766
+ pendingThreadArchiveMetadataByThreadId.set(normalizedThreadId, {
767
+ method,
768
+ cwd: readString(cwd),
769
+ });
770
+ ipc.ensureConnected();
771
+ flushPendingThreadArchiveMetadataBroadcasts();
772
+ }
773
+
774
+ function flushPendingThreadArchiveMetadataBroadcasts() {
775
+ for (const [threadId, pending] of pendingThreadArchiveMetadataByThreadId) {
776
+ const params = {
777
+ hostId,
778
+ conversationId: threadId,
779
+ };
780
+ if (pending.method === THREAD_ARCHIVED) {
781
+ params.cwd = pending.cwd;
782
+ }
783
+ if (!ipc.sendBroadcast(pending.method, params)) {
784
+ return;
785
+ }
786
+ pendingThreadArchiveMetadataByThreadId.delete(threadId);
787
+ }
788
+ }
789
+
790
+ function readArchiveCwd(threadId, params) {
791
+ return readString(params?.cwd)
792
+ || readString(conversations.get(threadId)?.cwd)
793
+ || readString(lastBroadcastStatesByThreadId.get(threadId)?.cwd);
794
+ }
795
+
796
+ function maybeYieldOwnedThreadForPeerArchive(envelope) {
797
+ if (envelope?.method !== THREAD_ARCHIVED) {
798
+ return false;
799
+ }
800
+ if (envelope.sourceClientId && envelope.sourceClientId === ipc.clientId) {
801
+ return true;
802
+ }
803
+ const params = envelope.params || {};
804
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
805
+ if (!threadId || !ownedThreadIds.has(threadId)) {
806
+ return true;
807
+ }
808
+
809
+ // Desktop archive is a list-level ownership signal; stop publishing snapshots
810
+ // so archived threads do not immediately reappear from bridge-owned state.
811
+ removeOwnedThread(threadId);
812
+ return true;
813
+ }
814
+
815
+ function upsertConversationFromThread(thread) {
816
+ const threadId = readString(thread?.id);
817
+ if (!threadId) {
818
+ return null;
819
+ }
820
+ const previous = conversations.get(threadId) || null;
821
+ const next = buildConversationStateFromThread(thread, {
822
+ previous,
823
+ hostId,
824
+ now,
825
+ });
826
+ runtimeSettingsStore?.attachToConversation?.(threadId, next);
827
+ conversations.set(threadId, next);
828
+ stopAwaitingInitialHistory(threadId);
829
+ return next;
830
+ }
831
+
832
+ function ensureConversation(threadId, seed = {}) {
833
+ const normalizedThreadId = readString(threadId);
834
+ if (!normalizedThreadId) {
835
+ return null;
836
+ }
837
+ let conversation = conversations.get(normalizedThreadId);
838
+ if (!conversation) {
839
+ conversation = createEmptyConversationState(normalizedThreadId, {
840
+ hostId,
841
+ now,
842
+ cwd: seed.cwd,
843
+ });
844
+ runtimeSettingsStore?.attachToConversation?.(normalizedThreadId, conversation);
845
+ conversations.set(normalizedThreadId, conversation);
846
+ }
847
+ return conversation;
848
+ }
849
+
850
+ function seedOwnedConversation(threadId, seed = {}) {
851
+ const normalizedThreadId = readString(threadId);
852
+ const existingConversation = normalizedThreadId ? conversations.get(normalizedThreadId) : null;
853
+ if (existingConversation) {
854
+ return existingConversation;
855
+ }
856
+ const cachedThread = normalizedThreadId ? cachedThreadsByThreadId.get(normalizedThreadId) : null;
857
+ if (cachedThread) {
858
+ return upsertConversationFromThread(cachedThread);
859
+ }
860
+ return ensureConversation(normalizedThreadId, seed);
861
+ }
862
+
863
+ function scheduleSnapshot(threadId) {
864
+ const normalizedThreadId = readString(threadId);
865
+ if (!normalizedThreadId || !ownedThreadIds.has(normalizedThreadId)) {
866
+ return;
867
+ }
868
+ dirtyThreadIds.add(normalizedThreadId);
869
+ ipc.ensureConnected();
870
+ if (snapshotTimer) {
871
+ return;
872
+ }
873
+ snapshotTimer = setTimeout(() => {
874
+ snapshotTimer = null;
875
+ flushSnapshots();
876
+ }, Math.max(0, snapshotDebounceMs));
877
+ snapshotTimer.unref?.();
878
+ }
879
+
880
+ function flushSnapshots() {
881
+ const pendingThreadIds = Array.from(dirtyThreadIds);
882
+ dirtyThreadIds.clear();
883
+ for (const threadId of pendingThreadIds) {
884
+ if (shouldDelayInitialSnapshotForHistory(threadId)) {
885
+ dirtyThreadIds.add(threadId);
886
+ continue;
887
+ }
888
+ if (!broadcastConversationState(threadId)) {
889
+ // Keep unsent snapshots dirty so the reconnect rebroadcast can retry them.
890
+ dirtyThreadIds.add(threadId);
891
+ }
892
+ }
893
+ }
894
+
895
+ // Blocks only the first Desktop snapshot for a newly-owned, unhydrated thread.
896
+ // Once a baseline was broadcast, later patches may stream normally.
897
+ function shouldDelayInitialSnapshotForHistory(threadId) {
898
+ const normalizedThreadId = readString(threadId);
899
+ if (!normalizedThreadId || !threadsAwaitingInitialHistoryByThreadId.has(normalizedThreadId)) {
900
+ return false;
901
+ }
902
+ if (lastBroadcastStatesByThreadId.has(normalizedThreadId)) {
903
+ stopAwaitingInitialHistory(normalizedThreadId);
904
+ return false;
905
+ }
906
+ requestInitialHistoryBaselineIfDue(normalizedThreadId);
907
+ return ownedThreadIds.has(normalizedThreadId)
908
+ && threadsAwaitingInitialHistoryByThreadId.has(normalizedThreadId);
909
+ }
910
+
911
+ // Re-requests the thread/read baseline with bounded backoff while the first
912
+ // snapshot stays blocked. Permanent failures release ownership to other mirrors.
913
+ function requestInitialHistoryBaselineIfDue(threadId) {
914
+ if (pendingThreadHydrationsByThreadId.has(threadId)) {
915
+ return;
916
+ }
917
+ const maxAttempts = Number.isFinite(initialHistoryMaxAttempts)
918
+ ? Math.max(1, Math.floor(initialHistoryMaxAttempts))
919
+ : DEFAULT_INITIAL_HISTORY_MAX_ATTEMPTS;
920
+ const attemptCount = initialHistoryAttemptCountByThreadId.get(threadId) || 0;
921
+ if (attemptCount >= maxAttempts) {
922
+ removeOwnedThread(threadId);
923
+ return;
924
+ }
925
+ const currentTime = now();
926
+ const retryAfter = initialHistoryRetryAfterByThreadId.get(threadId) || 0;
927
+ if (currentTime < retryAfter) {
928
+ scheduleInitialHistoryRetryWakeup(threadId, retryAfter - currentTime);
929
+ return;
930
+ }
931
+ clearInitialHistoryRetryWakeup(threadId);
932
+ const retryBaseMs = Number.isFinite(initialHistoryRetryMs)
933
+ ? Math.max(0, initialHistoryRetryMs)
934
+ : DEFAULT_INITIAL_HISTORY_RETRY_MS;
935
+ const retryDelayMs = retryBaseMs * (2 ** Math.min(attemptCount, 5));
936
+ initialHistoryAttemptCountByThreadId.set(threadId, attemptCount + 1);
937
+ initialHistoryRetryAfterByThreadId.set(threadId, currentTime + retryDelayMs);
938
+ hydrateOwnedThreadFromRead(threadId);
939
+ }
940
+
941
+ function stopAwaitingInitialHistory(threadId) {
942
+ threadsAwaitingInitialHistoryByThreadId.delete(threadId);
943
+ initialHistoryRetryAfterByThreadId.delete(threadId);
944
+ initialHistoryAttemptCountByThreadId.delete(threadId);
945
+ clearInitialHistoryRetryWakeup(threadId);
946
+ }
947
+
948
+ // Wakes blocked first-snapshot flushes once the thread/read retry window opens.
949
+ function scheduleInitialHistoryRetryWakeup(threadId, delayMs) {
950
+ if (initialHistoryRetryTimersByThreadId.has(threadId)) {
951
+ return;
952
+ }
953
+ const timer = setTimeout(() => {
954
+ initialHistoryRetryTimersByThreadId.delete(threadId);
955
+ if (ownedThreadIds.has(threadId) && threadsAwaitingInitialHistoryByThreadId.has(threadId)) {
956
+ scheduleSnapshot(threadId);
957
+ }
958
+ }, Math.max(0, delayMs));
959
+ timer.unref?.();
960
+ initialHistoryRetryTimersByThreadId.set(threadId, timer);
961
+ }
962
+
963
+ function clearInitialHistoryRetryWakeup(threadId) {
964
+ const timer = initialHistoryRetryTimersByThreadId.get(threadId);
965
+ if (!timer) {
966
+ return;
967
+ }
968
+ clearTimeout(timer);
969
+ initialHistoryRetryTimersByThreadId.delete(threadId);
970
+ }
971
+
972
+ // Refreshing insertion order makes the Map behave as an LRU; owned threads
973
+ // are exempt from eviction because their cache backs live snapshot rebuilds.
974
+ function rememberCachedThread(threadId, thread) {
975
+ cachedThreadsByThreadId.delete(threadId);
976
+ cachedThreadsByThreadId.set(threadId, cloneJSON(thread));
977
+ if (cachedThreadsByThreadId.size <= MAX_CACHED_THREADS) {
978
+ return;
979
+ }
980
+ for (const cachedThreadId of cachedThreadsByThreadId.keys()) {
981
+ if (cachedThreadsByThreadId.size <= MAX_CACHED_THREADS) {
982
+ return;
983
+ }
984
+ if (!ownedThreadIds.has(cachedThreadId)) {
985
+ cachedThreadsByThreadId.delete(cachedThreadId);
986
+ }
987
+ }
988
+ }
989
+
990
+ function hydrateOwnedThreadFromRead(threadId) {
991
+ const normalizedThreadId = readString(threadId);
992
+ if (!normalizedThreadId || pendingThreadHydrationsByThreadId.has(normalizedThreadId)) {
993
+ return;
994
+ }
995
+ const hydration = Promise.resolve()
996
+ .then(() => sendCodexRequest("thread/read", { threadId: normalizedThreadId }))
997
+ .then((result) => {
998
+ const thread = readThreadFromPayload(result);
999
+ if (!thread?.id) {
1000
+ return;
1001
+ }
1002
+ rememberCachedThread(thread.id, thread);
1003
+ if (ownedThreadIds.has(thread.id)) {
1004
+ upsertConversationFromThread(thread);
1005
+ }
1006
+ })
1007
+ .catch((error) => {
1008
+ console.warn(`${logPrefix} desktop IPC live owner thread/read hydration failed for ${normalizedThreadId}: ${error?.message || "unknown error"}`);
1009
+ })
1010
+ .finally(() => {
1011
+ pendingThreadHydrationsByThreadId.delete(normalizedThreadId);
1012
+ if (dirtyThreadIds.has(normalizedThreadId) && ownedThreadIds.has(normalizedThreadId)) {
1013
+ scheduleSnapshot(normalizedThreadId);
1014
+ }
1015
+ });
1016
+ pendingThreadHydrationsByThreadId.set(normalizedThreadId, hydration);
1017
+ }
1018
+
1019
+ function broadcastAllOwnedSnapshots() {
1020
+ for (const threadId of ownedThreadIds) {
1021
+ if (shouldDelayInitialSnapshotForHistory(threadId)) {
1022
+ dirtyThreadIds.add(threadId);
1023
+ continue;
1024
+ }
1025
+ if (broadcastConversationState(threadId, { forceSnapshot: true })) {
1026
+ dirtyThreadIds.delete(threadId);
1027
+ } else {
1028
+ dirtyThreadIds.add(threadId);
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ // Returns false only when a pending state change could not be delivered yet.
1034
+ function broadcastConversationState(threadId, { forceSnapshot = false } = {}) {
1035
+ const conversationState = conversations.get(threadId);
1036
+ if (!conversationState || !ownedThreadIds.has(threadId)) {
1037
+ return true;
1038
+ }
1039
+ runtimeSettingsStore?.attachToConversation?.(threadId, conversationState);
1040
+ if (shouldDelayInitialSnapshotForHistory(threadId)) {
1041
+ return false;
1042
+ }
1043
+ const currentRevision = streamRevisionsByThreadId.get(threadId) ?? 0;
1044
+ const previousState = lastBroadcastStatesByThreadId.get(threadId) || null;
1045
+ if (!forceSnapshot && previousState) {
1046
+ // Diff straight against the live state: every patch value is deep-cloned
1047
+ // as it is collected, so the per-flush O(state) snapshot clone is not
1048
+ // needed on the streaming path.
1049
+ const patches = buildConversationStatePatches(previousState, conversationState, {
1050
+ maxPatchCount,
1051
+ maxPatchBytes,
1052
+ });
1053
+ if (patches && patches.length === 0) {
1054
+ return true;
1055
+ }
1056
+ if (patches && ipc.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
1057
+ conversationId: threadId,
1058
+ hostId,
1059
+ version: METHOD_VERSION_BY_NAME.get(THREAD_STREAM_STATE_CHANGED) || 1,
1060
+ remodexOwnerSource: REMODEX_LIVE_OWNER_SOURCE,
1061
+ change: {
1062
+ type: "patches",
1063
+ baseRevision: currentRevision,
1064
+ revision: currentRevision + 1,
1065
+ patches,
1066
+ },
1067
+ })) {
1068
+ streamRevisionsByThreadId.set(threadId, currentRevision + 1);
1069
+ // The baseline advances by replaying the emitted patches (their values
1070
+ // are already private clones); a full clone happens only if that fails.
1071
+ if (!applyPatchesToBaselineState(previousState, patches)) {
1072
+ lastBroadcastStatesByThreadId.set(threadId, cloneJSON(conversationState));
1073
+ }
1074
+ return true;
1075
+ }
1076
+ }
1077
+
1078
+ // Snapshot broadcasts serialize synchronously, so the live state can be
1079
+ // passed through; only the retained baseline needs its own copy.
1080
+ if (ipc.sendBroadcast(THREAD_STREAM_STATE_CHANGED, {
1081
+ conversationId: threadId,
1082
+ hostId,
1083
+ version: METHOD_VERSION_BY_NAME.get(THREAD_STREAM_STATE_CHANGED) || 1,
1084
+ remodexOwnerSource: REMODEX_LIVE_OWNER_SOURCE,
1085
+ change: {
1086
+ type: "snapshot",
1087
+ revision: currentRevision + 1,
1088
+ conversationState,
1089
+ },
1090
+ })) {
1091
+ streamRevisionsByThreadId.set(threadId, currentRevision + 1);
1092
+ lastBroadcastStatesByThreadId.set(threadId, cloneJSON(conversationState));
1093
+ return true;
1094
+ }
1095
+ return false;
1096
+ }
1097
+
1098
+ function handlePeerBroadcast(envelope) {
1099
+ if (envelope?.method === CLIENT_STATUS_CHANGED) {
1100
+ broadcastAllOwnedSnapshots();
1101
+ return;
1102
+ }
1103
+ if (maybeYieldOwnedThreadForPeerArchive(envelope)) {
1104
+ return;
1105
+ }
1106
+ if (envelope?.method !== THREAD_STREAM_STATE_CHANGED) {
1107
+ return;
1108
+ }
1109
+ const params = envelope.params || {};
1110
+ const threadId = readString(params.conversationId) || readString(params.conversation_id);
1111
+ if (!threadId || !ownedThreadIds.has(threadId)) {
1112
+ return;
1113
+ }
1114
+ if (envelope.sourceClientId && envelope.sourceClientId === ipc.clientId) {
1115
+ return;
1116
+ }
1117
+ if (!isPeerOwnershipBroadcast(params)) {
1118
+ return;
1119
+ }
1120
+ if (hasActiveLocalTurn(threadId) && !conversationSnapshotShowsActiveTurn(params.change)) {
1121
+ // Desktop re-broadcasts idle snapshots for threads the user merely viewed
1122
+ // (and replays them on reconnect). Those may claim an idle thread, but a
1123
+ // thread whose local turn is still running yields only to a peer snapshot
1124
+ // proving the peer runtime is executing it.
1125
+ return;
1126
+ }
1127
+
1128
+ // Another Codex frontend is actively owning this stream. Drop bridge ownership
1129
+ // and all cached conversation state so a later re-claim rehydrates fresh data
1130
+ // instead of republishing stale turns and requests.
1131
+ removeOwnedThread(threadId);
1132
+ }
1133
+
1134
+ function isPeerOwnershipBroadcast(params) {
1135
+ if (readString(params?.remodexOwnerSource) === REMODEX_LIVE_OWNER_SOURCE) {
1136
+ return false;
1137
+ }
1138
+ const changeType = normalizeToken(params?.change?.type);
1139
+ return changeType === "snapshot";
1140
+ }
1141
+
1142
+ function canHandleFollowerRequest(envelope) {
1143
+ const method = readString(envelope?.request?.method || envelope?.method);
1144
+ const params = envelope?.request?.params || envelope?.params || {};
1145
+ if (!SUPPORTED_FOLLOWER_REQUEST_METHODS.has(method)) {
1146
+ return false;
1147
+ }
1148
+ const threadId = readConversationIdFromFollowerParams(params);
1149
+ return Boolean(threadId && ownedThreadIds.has(threadId));
1150
+ }
1151
+
1152
+ async function handleFollowerRequest(envelope) {
1153
+ const method = readString(envelope?.method);
1154
+ const params = envelope?.params && typeof envelope.params === "object" ? envelope.params : {};
1155
+ const conversationId = readConversationIdFromFollowerParams(params);
1156
+ if (!conversationId || !ownedThreadIds.has(conversationId)) {
1157
+ throw new Error("conversation-not-owned");
1158
+ }
1159
+
1160
+ switch (method) {
1161
+ case "thread-follower-start-turn":
1162
+ return await handleFollowerStartTurn(conversationId, params);
1163
+ case "thread-follower-load-complete-history":
1164
+ return await handleFollowerLoadCompleteHistory(conversationId);
1165
+ case "thread-follower-compact-thread":
1166
+ return await sendCodexRequest("thread/compact/start", { threadId: conversationId });
1167
+ case "thread-follower-steer-turn":
1168
+ return await handleFollowerSteerTurn(conversationId, params);
1169
+ case "thread-follower-interrupt-turn":
1170
+ return await handleFollowerInterruptTurn(conversationId, params);
1171
+ case "thread-follower-command-approval-decision":
1172
+ return sendServerRequestResponse(conversationId, params.requestId, { decision: params.decision });
1173
+ case "thread-follower-file-approval-decision":
1174
+ return sendServerRequestResponse(
1175
+ conversationId,
1176
+ params.requestId,
1177
+ followerApprovalResultForRequest(conversationId, params)
1178
+ );
1179
+ case "thread-follower-permissions-request-approval-response":
1180
+ return sendServerRequestResponse(conversationId, params.requestId, params.response);
1181
+ case "thread-follower-submit-user-input":
1182
+ return sendServerRequestResponse(conversationId, params.requestId, params.response);
1183
+ case "thread-follower-submit-mcp-server-elicitation-response":
1184
+ return sendServerRequestResponse(conversationId, params.requestId, params.response);
1185
+ case "thread-follower-set-model-and-reasoning":
1186
+ return applyFollowerModelAndReasoning(conversationId, params);
1187
+ case "thread-follower-set-collaboration-mode":
1188
+ return applyFollowerCollaborationMode(conversationId, params);
1189
+ case "thread-follower-update-thread-settings":
1190
+ return applyFollowerThreadSettings(conversationId, params.threadSettings);
1191
+ case "thread-follower-set-queued-follow-ups-state":
1192
+ return applyFollowerQueuedFollowUps(conversationId, params.state);
1193
+ case "thread-follower-edit-last-user-turn":
1194
+ throw new Error("thread-follower-edit-last-user-turn is not supported by Remodex yet.");
1195
+ default:
1196
+ throw new Error(`Unsupported follower request: ${method}`);
1197
+ }
1198
+ }
1199
+
1200
+ // Desktop calls this when it opens a followed conversation: it wants the
1201
+ // owner's complete history in the stream, then waits for a snapshot carrying
1202
+ // the returned revision before rendering. Rehydrate from the app-server so
1203
+ // history is complete, then force-broadcast a fresh snapshot.
1204
+ async function handleFollowerLoadCompleteHistory(conversationId) {
1205
+ try {
1206
+ const result = await sendCodexRequest("thread/read", { threadId: conversationId });
1207
+ const thread = readThreadFromPayload(result);
1208
+ if (thread?.id) {
1209
+ rememberCachedThread(thread.id, thread);
1210
+ if (ownedThreadIds.has(thread.id)) {
1211
+ upsertConversationFromThread(thread);
1212
+ }
1213
+ }
1214
+ } catch {
1215
+ // Not materialized yet: the live conversation state is still authoritative.
1216
+ }
1217
+ if (!broadcastConversationState(conversationId, { forceSnapshot: true })) {
1218
+ throw new Error("no-client-found: thread stream owner became unavailable");
1219
+ }
1220
+ const revision = streamRevisionsByThreadId.get(conversationId);
1221
+ if (revision == null) {
1222
+ throw new Error("no-client-found: thread stream owner became unavailable");
1223
+ }
1224
+ return { revision };
1225
+ }
1226
+
1227
+ async function handleFollowerStartTurn(conversationId, params) {
1228
+ const rawTurnStartParams = params.turnStartParams
1229
+ || params.turn_start_params
1230
+ || params.turnStart
1231
+ || params;
1232
+ const codexParams = mergeFollowerRuntimeOverrides(conversationId, sanitizeTurnStartParams({
1233
+ ...rawTurnStartParams,
1234
+ threadId: conversationId,
1235
+ }));
1236
+ const normalizedParams = await Promise.resolve(normalizeTurnStartParams(cloneJSON(codexParams)));
1237
+ const nextCodexParams = normalizedParams && typeof normalizedParams === "object" && !Array.isArray(normalizedParams)
1238
+ ? normalizedParams
1239
+ : codexParams;
1240
+ markOwnedThread(conversationId);
1241
+ const senderRequestId = params.senderRequestId || params.sender_request_id;
1242
+ const isKnownHeldPhoneStart = Boolean(
1243
+ requestIdKey(senderRequestId)
1244
+ && pendingTurnStartEntriesByRequestId.has(requestIdKey(senderRequestId))
1245
+ );
1246
+ const pendingEntry = rememberPendingTurnStart(
1247
+ conversationId,
1248
+ nextCodexParams,
1249
+ senderRequestId
1250
+ );
1251
+ if (pendingEntry) {
1252
+ insertOptimisticPendingTurn(conversationId, pendingEntry);
1253
+ scheduleSnapshot(conversationId);
1254
+ }
1255
+ try {
1256
+ const turnStartResult = await sendCodexRequest("turn/start", nextCodexParams);
1257
+ commitAcceptedRuntimeSettings(
1258
+ conversationId,
1259
+ nextCodexParams,
1260
+ isKnownHeldPhoneStart ? "phone" : "desktop",
1261
+ readTurnIdFromResult(turnStartResult)
1262
+ );
1263
+ scheduleSnapshot(conversationId);
1264
+ if (!isKnownHeldPhoneStart) {
1265
+ mirrorFollowerUserPromptToPhone(conversationId, nextCodexParams, turnStartResult);
1266
+ }
1267
+ // Codex Desktop's own thread-follower-start-turn-for-host handler replies
1268
+ // with { result: <turnStartResult> }; a follower reads response.result.turn
1269
+ // off that wrapper. Returning the raw result made Desktop read `.turn` on
1270
+ // undefined ("Error creating task"), even though the turn did start.
1271
+ return { result: turnStartResult ?? null };
1272
+ } catch (error) {
1273
+ discardPendingTurnStartEntry(conversationId, pendingEntry);
1274
+ throw error;
1275
+ }
1276
+ }
1277
+
1278
+ function mirrorFollowerUserPromptToPhone(threadId, turnStartParams, turnStartResult = null) {
1279
+ const text = visibleUserPromptFromInputEntries(turnStartParams?.input);
1280
+ if (!text) {
1281
+ return;
1282
+ }
1283
+ const turnId = readString(turnStartResult?.turn?.id)
1284
+ || readString(turnStartResult?.turnId)
1285
+ || readString(turnStartResult?.turn_id);
1286
+ sendPhoneNotification(JSON.stringify({
1287
+ method: "codex/event/user_message",
1288
+ params: {
1289
+ threadId,
1290
+ // Mirror the conversation projector's synthetic prompt identity
1291
+ // ("<turnId>:input") so later projected snapshots and history reads
1292
+ // reconcile into this row instead of appending a duplicate bubble.
1293
+ ...(turnId ? { turnId, id: `${turnId}:input` } : {}),
1294
+ message: text,
1295
+ text,
1296
+ remodexDesktopMirror: true,
1297
+ remodexDesktopIpcMirror: true,
1298
+ remodexActionSource: REMODEX_LIVE_OWNER_SOURCE,
1299
+ createdAt: now(),
1300
+ },
1301
+ }));
1302
+ }
1303
+
1304
+ async function handleFollowerSteerTurn(conversationId, params) {
1305
+ const rawSteerParams = params.turnSteerParams
1306
+ || params.turn_steer_params
1307
+ || params;
1308
+ const expectedTurnId = readString(rawSteerParams.expectedTurnId)
1309
+ || readString(rawSteerParams.expected_turn_id)
1310
+ || activeTurnIdForConversation(conversationId);
1311
+ if (!expectedTurnId) {
1312
+ throw new Error("Missing expectedTurnId for follower steer request.");
1313
+ }
1314
+ return await sendCodexRequest("turn/steer", {
1315
+ threadId: conversationId,
1316
+ input: Array.isArray(rawSteerParams.input) ? rawSteerParams.input : [],
1317
+ expectedTurnId,
1318
+ });
1319
+ }
1320
+
1321
+ async function handleFollowerInterruptTurn(conversationId, params) {
1322
+ const turnId = readString(params.turnId)
1323
+ || readString(params.turn_id)
1324
+ || activeTurnIdForConversation(conversationId);
1325
+ if (!turnId) {
1326
+ throw new Error("Missing turnId for follower interrupt request.");
1327
+ }
1328
+ return await sendCodexRequest("turn/interrupt", {
1329
+ threadId: conversationId,
1330
+ turnId,
1331
+ });
1332
+ }
1333
+
1334
+ // Desktop follower approvals only carry decision-style payloads, but app-server
1335
+ // permission prompts expect a grant object, mirroring the phone response path.
1336
+ function followerApprovalResultForRequest(conversationId, params) {
1337
+ const requestId = requestIdKey(params.requestId);
1338
+ const pendingRequest = (conversations.get(conversationId)?.requests || [])
1339
+ .find((request) => requestIdKey(request?.id) === requestId);
1340
+ if (readString(pendingRequest?.method) !== "item/permissions/requestApproval") {
1341
+ return { decision: params.decision };
1342
+ }
1343
+
1344
+ const decision = readString(params.decision);
1345
+ const grantsRequestedPermissions = decision === "accept" || decision === "acceptForSession";
1346
+ const requestedPermissions = pendingRequest?.params?.permissions;
1347
+ return {
1348
+ permissions: grantsRequestedPermissions && isPlainJSONObject(requestedPermissions)
1349
+ ? cloneJSON(requestedPermissions)
1350
+ : {},
1351
+ scope: decision === "acceptForSession" ? "session" : "turn",
1352
+ };
1353
+ }
1354
+
1355
+ function sendServerRequestResponse(conversationId, requestId, result) {
1356
+ const normalizedRequestId = requestIdKey(requestId);
1357
+ if (!normalizedRequestId) {
1358
+ throw new Error("Missing requestId for follower server response.");
1359
+ }
1360
+ // Desktop may echo a coerced (stringified) request id; reply with the exact
1361
+ // id the app-server used so the pending server request actually resolves.
1362
+ const pendingRequest = (conversations.get(conversationId)?.requests || [])
1363
+ .find((request) => requestIdKey(request?.id) === normalizedRequestId);
1364
+ sendRawCodexMessage(JSON.stringify({
1365
+ id: pendingRequest ? pendingRequest.id : requestId,
1366
+ result: result || {},
1367
+ }));
1368
+ return { ok: true };
1369
+ }
1370
+
1371
+ // Desktop runtime option changes are persisted as per-thread overrides and
1372
+ // merged into later Desktop-origin turn starts, so acknowledging them is honest
1373
+ // instead of a cosmetic broadcast-only update.
1374
+ function applyFollowerModelAndReasoning(conversationId, params) {
1375
+ const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1376
+ const conversation = conversations.get(conversationId);
1377
+ if (Object.prototype.hasOwnProperty.call(params, "model")) {
1378
+ overrides.model = readString(params.model);
1379
+ if (conversation) {
1380
+ conversation.latestModel = overrides.model;
1381
+ }
1382
+ }
1383
+ if (Object.prototype.hasOwnProperty.call(params, "reasoningEffort")) {
1384
+ overrides.effort = params.reasoningEffort || null;
1385
+ if (conversation) {
1386
+ conversation.latestReasoningEffort = overrides.effort;
1387
+ }
1388
+ }
1389
+ if (Object.prototype.hasOwnProperty.call(params, "serviceTier")) {
1390
+ overrides.serviceTier = readString(params.serviceTier) || null;
1391
+ if (conversation) {
1392
+ conversation.latestServiceTier = overrides.serviceTier;
1393
+ }
1394
+ }
1395
+ followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1396
+ if (conversation) {
1397
+ scheduleSnapshot(conversationId);
1398
+ }
1399
+ return { ok: true };
1400
+ }
1401
+
1402
+ function applyFollowerCollaborationMode(conversationId, params) {
1403
+ if (!params.collaborationMode) {
1404
+ return { ok: true };
1405
+ }
1406
+ const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1407
+ overrides.collaborationMode = cloneJSON(params.collaborationMode);
1408
+ followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1409
+ const conversation = conversations.get(conversationId);
1410
+ if (conversation) {
1411
+ conversation.latestCollaborationMode = cloneJSON(params.collaborationMode);
1412
+ scheduleSnapshot(conversationId);
1413
+ }
1414
+ return { ok: true };
1415
+ }
1416
+
1417
+ // Current Desktop sends the whole thread-settings object; persist it so the
1418
+ // composer fields stay accurate and future Desktop-origin turns pick it up.
1419
+ function applyFollowerThreadSettings(conversationId, threadSettings) {
1420
+ if (!threadSettings || typeof threadSettings !== "object" || Array.isArray(threadSettings)) {
1421
+ return { ok: true };
1422
+ }
1423
+ const overrides = followerRuntimeOverridesByThreadId.get(conversationId) || {};
1424
+ const model = readString(threadSettings.model)
1425
+ || readString(threadSettings.collaborationMode?.settings?.model);
1426
+ const effort = threadSettings.effort;
1427
+ const hasServiceTier = Object.prototype.hasOwnProperty.call(threadSettings, "serviceTier");
1428
+ if (model) {
1429
+ overrides.model = model;
1430
+ }
1431
+ if (effort !== undefined) {
1432
+ overrides.effort = effort ?? null;
1433
+ }
1434
+ if (hasServiceTier) {
1435
+ overrides.serviceTier = readString(threadSettings.serviceTier) || null;
1436
+ }
1437
+ if (threadSettings.collaborationMode && typeof threadSettings.collaborationMode === "object") {
1438
+ overrides.collaborationMode = cloneJSON(threadSettings.collaborationMode);
1439
+ }
1440
+ followerRuntimeOverridesByThreadId.set(conversationId, overrides);
1441
+
1442
+ const conversation = conversations.get(conversationId);
1443
+ if (conversation) {
1444
+ conversation.latestThreadSettings = {
1445
+ ...(conversation.latestThreadSettings && typeof conversation.latestThreadSettings === "object"
1446
+ ? conversation.latestThreadSettings
1447
+ : {}),
1448
+ ...cloneJSON(threadSettings),
1449
+ };
1450
+ if (model) {
1451
+ conversation.latestModel = model;
1452
+ }
1453
+ if (effort !== undefined) {
1454
+ conversation.latestReasoningEffort = effort ?? null;
1455
+ }
1456
+ if (hasServiceTier) {
1457
+ conversation.latestServiceTier = overrides.serviceTier;
1458
+ }
1459
+ if (overrides.collaborationMode) {
1460
+ conversation.latestCollaborationMode = cloneJSON(overrides.collaborationMode);
1461
+ }
1462
+ scheduleSnapshot(conversationId);
1463
+ }
1464
+ return { ok: true };
1465
+ }
1466
+
1467
+ // Desktop followers hand the owner the full queue map and expect it to run
1468
+ // entries between turns; store it, mirror it to every window, and let the
1469
+ // turn/completed hook drain it.
1470
+ function applyFollowerQueuedFollowUps(conversationId, state) {
1471
+ const messages = state && typeof state === "object" && !Array.isArray(state)
1472
+ ? cloneJSON(state[conversationId] ?? [])
1473
+ : [];
1474
+ if (Array.isArray(messages) && messages.length > 0) {
1475
+ queuedFollowUpsByThreadId.set(conversationId, messages);
1476
+ } else {
1477
+ queuedFollowUpsByThreadId.delete(conversationId);
1478
+ }
1479
+ broadcastQueuedFollowUps(conversationId);
1480
+ const conversation = conversations.get(conversationId);
1481
+ const hasActiveTurn = conversation
1482
+ ? conversation.turns.some((turn) => normalizeToken(turn?.status) === "inprogress")
1483
+ : false;
1484
+ if (!hasActiveTurn) {
1485
+ runNextQueuedFollowUp(conversationId);
1486
+ }
1487
+ return { ok: true };
1488
+ }
1489
+
1490
+ function broadcastQueuedFollowUps(conversationId) {
1491
+ ipc.sendBroadcast(THREAD_QUEUED_FOLLOWUPS_CHANGED, {
1492
+ conversationId,
1493
+ messages: cloneJSON(queuedFollowUpsByThreadId.get(conversationId) ?? []),
1494
+ });
1495
+ }
1496
+
1497
+ function runNextQueuedFollowUp(threadId) {
1498
+ const queue = queuedFollowUpsByThreadId.get(threadId);
1499
+ if (!Array.isArray(queue) || queue.length === 0 || runningQueuedFollowUpThreadIds.has(threadId)) {
1500
+ return;
1501
+ }
1502
+ const entry = queue[0];
1503
+ if (entry?.pausedReason) {
1504
+ return;
1505
+ }
1506
+ const text = readString(entry?.context?.text)
1507
+ || readString(entry?.text)
1508
+ || readString(entry?.prompt);
1509
+ if (!text) {
1510
+ // Unrecognized entry shape: pause it visibly instead of discarding the
1511
+ // user's draft. The queue stays blocked (matching Desktop's first-entry
1512
+ // semantics) and the user can edit or resend it from any window.
1513
+ if (!entry || typeof entry !== "object") {
1514
+ queue.shift();
1515
+ if (queue.length === 0) {
1516
+ queuedFollowUpsByThreadId.delete(threadId);
1517
+ }
1518
+ broadcastQueuedFollowUps(threadId);
1519
+ return;
1520
+ }
1521
+ console.warn(`${logPrefix} desktop queued follow-up entry has no extractable text; pausing it for ${threadId}`);
1522
+ entry.pausedReason = "remodex-unsupported-entry";
1523
+ broadcastQueuedFollowUps(threadId);
1524
+ return;
1525
+ }
1526
+
1527
+ runningQueuedFollowUpThreadIds.add(threadId);
1528
+ const conversation = conversations.get(threadId);
1529
+ const startParams = mergeFollowerRuntimeOverrides(threadId, sanitizeTurnStartParams({
1530
+ threadId,
1531
+ input: [{ type: "text", text }],
1532
+ cwd: readString(entry?.cwd) || readString(conversation?.cwd) || undefined,
1533
+ }));
1534
+ let queuedTurnParams = startParams;
1535
+ Promise.resolve()
1536
+ .then(() => normalizeTurnStartParams(cloneJSON(startParams)))
1537
+ .then((normalized) => {
1538
+ const params = normalized && typeof normalized === "object" && !Array.isArray(normalized)
1539
+ ? normalized
1540
+ : startParams;
1541
+ queuedTurnParams = params;
1542
+ const pendingEntry = rememberPendingTurnStart(threadId, params);
1543
+ if (pendingEntry) {
1544
+ insertOptimisticPendingTurn(threadId, pendingEntry);
1545
+ scheduleSnapshot(threadId);
1546
+ }
1547
+ return sendCodexRequest("turn/start", params);
1548
+ })
1549
+ .then((turnStartResult) => {
1550
+ commitAcceptedRuntimeSettings(
1551
+ threadId,
1552
+ queuedTurnParams,
1553
+ "desktop",
1554
+ readTurnIdFromResult(turnStartResult)
1555
+ );
1556
+ scheduleSnapshot(threadId);
1557
+ queue.shift();
1558
+ if (queue.length === 0) {
1559
+ queuedFollowUpsByThreadId.delete(threadId);
1560
+ }
1561
+ broadcastQueuedFollowUps(threadId);
1562
+ })
1563
+ .catch((error) => {
1564
+ console.warn(`${logPrefix} desktop queued follow-up failed for ${threadId}: ${error?.message || "unknown error"}`);
1565
+ })
1566
+ .finally(() => {
1567
+ runningQueuedFollowUpThreadIds.delete(threadId);
1568
+ });
1569
+ }
1570
+
1571
+ // Fills follower turn-start params with Desktop-selected runtime overrides when
1572
+ // the request itself does not specify them. Phone-origin turns are untouched.
1573
+ function mergeFollowerRuntimeOverrides(conversationId, params) {
1574
+ const persisted = runtimeSettingsStore?.get?.(conversationId) || null;
1575
+ const persistedOverrides = persisted ? {
1576
+ model: persisted.model,
1577
+ effort: persisted.reasoningEffort,
1578
+ serviceTier: persisted.serviceTier,
1579
+ } : null;
1580
+ const liveOverrides = followerRuntimeOverridesByThreadId.get(conversationId) || null;
1581
+ const overrides = persistedOverrides || liveOverrides
1582
+ ? { ...(persistedOverrides || {}), ...(liveOverrides || {}) }
1583
+ : null;
1584
+ if (!overrides) {
1585
+ return params;
1586
+ }
1587
+ const merged = { ...params };
1588
+ if (overrides.model && !readString(merged.model)) {
1589
+ merged.model = overrides.model;
1590
+ }
1591
+ if (overrides.effort != null && merged.effort == null) {
1592
+ merged.effort = overrides.effort;
1593
+ }
1594
+ if (overrides.serviceTier && merged.serviceTier == null) {
1595
+ merged.serviceTier = overrides.serviceTier;
1596
+ }
1597
+ if (overrides.collaborationMode && merged.collaborationMode == null) {
1598
+ merged.collaborationMode = cloneJSON(overrides.collaborationMode);
1599
+ }
1600
+ return merged;
1601
+ }
1602
+
1603
+ function commitAcceptedRuntimeSettings(threadId, params, source, turnId) {
1604
+ try {
1605
+ const settings = runtimeSettingsStore?.commit?.(threadId, params, { source, turnId });
1606
+ const conversation = conversations.get(threadId);
1607
+ if (settings && conversation) {
1608
+ runtimeSettingsStore.attachToConversation(threadId, conversation);
1609
+ }
1610
+ return settings;
1611
+ } catch (error) {
1612
+ console.warn(`[remodex] runtime settings persistence failed: ${error.message}`);
1613
+ return null;
1614
+ }
1615
+ }
1616
+
1617
+ // True while the bridge's app-server is executing a turn for this thread, or
1618
+ // has just been asked to start one (pending starts clear on error or on the
1619
+ // matching turn/started snapshot). Queued follow-ups count as active work
1620
+ // too: releasing ownership deletes the queue, so yielding an "idle" thread
1621
+ // that still holds drafts would silently discard them. Such threads must not
1622
+ // be handed to peers or released on unsubscribe.
1623
+ function hasActiveLocalTurn(threadId) {
1624
+ if (pendingTurnStartParamsByThreadId.has(threadId)) {
1625
+ return true;
1626
+ }
1627
+ if ((queuedFollowUpsByThreadId.get(threadId) || []).length > 0) {
1628
+ return true;
1629
+ }
1630
+ const turns = conversations.get(threadId)?.turns || [];
1631
+ return turns.some((turn) => {
1632
+ const status = normalizeToken(turn?.status);
1633
+ return status === "inprogress" || status === "running" || status === "active";
1634
+ });
1635
+ }
1636
+
1637
+ function activeTurnIdForConversation(conversationId) {
1638
+ const turns = conversations.get(conversationId)?.turns || [];
1639
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
1640
+ const turn = turns[index];
1641
+ const status = normalizeToken(turn?.status);
1642
+ const turnId = readString(turn?.turnId) || readString(turn?.id);
1643
+ if (turnId && (!status || status === "inprogress" || status === "running" || status === "active")) {
1644
+ return turnId;
1645
+ }
1646
+ }
1647
+ const latestTurn = turns[turns.length - 1];
1648
+ return readString(latestTurn?.turnId) || readString(latestTurn?.id);
1649
+ }
1650
+
1651
+ return {
1652
+ observeInbound,
1653
+ observeOutbound,
1654
+ stopAll,
1655
+ // True while the bridge's app-server stream is authoritative for this
1656
+ // thread; used to keep fallback mirrors (rollout tail) silent.
1657
+ isThreadOwned(threadId) {
1658
+ return ownedThreadIds.has(readString(threadId));
1659
+ },
1660
+ isFreshThreadOwned(threadId) {
1661
+ const normalizedThreadId = readString(threadId);
1662
+ if (!ownedThreadIds.has(normalizedThreadId)) {
1663
+ return false;
1664
+ }
1665
+ // This bridge owns the local app-server turn directly. A quiet active
1666
+ // turn remains authoritative; releasing it solely because no patch
1667
+ // arrived for 20 seconds starts the rollout mirror as a second source.
1668
+ if (hasActiveLocalTurn(normalizedThreadId)) {
1669
+ return true;
1670
+ }
1671
+ const updatedAt = Number(conversations.get(normalizedThreadId)?.updatedAt) || 0;
1672
+ return now() - updatedAt <= liveOwnershipFreshnessMs;
1673
+ },
1674
+ _debugSnapshot(threadId) {
1675
+ return cloneJSON(conversations.get(threadId) || null);
1676
+ },
1677
+ };
1678
+ }
1679
+
1680
+ function createDisabledDesktopIpcLiveOwner() {
1681
+ return {
1682
+ observeInbound() {},
1683
+ observeOutbound() {},
1684
+ stopAll() {},
1685
+ isThreadOwned() {
1686
+ return false;
1687
+ },
1688
+ isFreshThreadOwned() {
1689
+ return false;
1690
+ },
1691
+ };
1692
+ }
1693
+
1694
+ // Attaches the cached turn/start prompt to a just-started turn as
1695
+ // turn.params.input. Desktop builds the user bubble from params.input and
1696
+ // treats any userMessage item that does not dedupe against it as a mid-turn
1697
+ // steer ("Steered conversation"), so the prompt must live ONLY in params.
1698
+ // Pending prompts are consumed FIFO so rapid consecutive starts stay matched.
1699
+ // Desktop's composer reads the followed thread's model/effort from the
1700
+ // conversation-level fields; without them it falls back to showing "Custom".
1701
+ // Desktop's prompt dedupe allows only these item types to precede the initial
1702
+ // user message; anything else makes a userMessage item render as a steer.
1703
+ // Joins the human-readable text of user input/content entries so the initial
1704
+ // prompt can be matched by meaning instead of exact entry shape (the phone
1705
+ // sends input_text entries while the app-server echoes text entries).
1706
+ // Desktop renders the turn's user bubble from turn.params.input and labels any
1707
+ // userMessage item that fails its dedupe as "Steered conversation". Keep the
1708
+ // initial prompt ONLY in params: drop the first user item that duplicates it,
1709
+ // or adopt it into params.input for hydrated turns that arrived item-only.
1710
+ // Later userMessage items are genuine mid-turn steers and stay untouched.
1711
+ // Replays just-emitted patches onto the retained broadcast baseline. The
1712
+ // baseline is a private copy and patch values are already private clones, so
1713
+ // in-place mutation is safe and avoids re-cloning the whole state per flush.
1714
+ // Desktop's user-bubble renderer extracts images from input entries shaped
1715
+ // {type: "image", url}; runtimes sometimes fall back to the image_url shape,
1716
+ // which Desktop would silently skip.
1717
+ function normalizeInputEntriesForDesktop(input) {
1718
+ if (!Array.isArray(input)) {
1719
+ return [];
1720
+ }
1721
+ return input.map((entry) => {
1722
+ if (!entry || typeof entry !== "object") {
1723
+ return entry;
1724
+ }
1725
+ if (normalizeToken(entry.type) === "imageurl") {
1726
+ const url = readString(entry.url)
1727
+ || readString(entry.image_url?.url)
1728
+ || readString(entry.imageUrl?.url)
1729
+ || readString(entry.image_url)
1730
+ || readString(entry.imageUrl);
1731
+ if (url) {
1732
+ return { type: "image", url };
1733
+ }
1734
+ }
1735
+ return entry;
1736
+ });
1737
+ }
1738
+
1739
+ function sanitizeTurnStartParams(params) {
1740
+ const sanitized = {};
1741
+ for (const [key, value] of Object.entries(params || {})) {
1742
+ if (ALLOWED_TURN_START_PARAM_KEYS.has(key)) {
1743
+ sanitized[key] = value;
1744
+ }
1745
+ }
1746
+ if (!Array.isArray(sanitized.input)) {
1747
+ sanitized.input = [];
1748
+ }
1749
+ return sanitized;
1750
+ }
1751
+
1752
+ function readThreadFromResponse(message) {
1753
+ const result = message?.result || message?.payload || {};
1754
+ return readThreadFromPayload(result);
1755
+ }
1756
+
1757
+ function readTurnIdFromResult(result) {
1758
+ return readString(result?.turn?.id)
1759
+ || readString(result?.turnId)
1760
+ || readString(result?.turn_id)
1761
+ || readString(result?.result?.turn?.id)
1762
+ || readString(result?.result?.turnId)
1763
+ || readString(result?.result?.turn_id);
1764
+ }
1765
+
1766
+ function readThreadFromPayload(result) {
1767
+ if (!result || typeof result !== "object") {
1768
+ return null;
1769
+ }
1770
+ return result.thread && typeof result.thread === "object"
1771
+ ? result.thread
1772
+ : result;
1773
+ }
1774
+
1775
+ function readConversationIdFromFollowerParams(params) {
1776
+ return readString(params?.conversationId)
1777
+ || readString(params?.conversation_id)
1778
+ || readString(params?.threadId)
1779
+ || readString(params?.thread_id)
1780
+ || readString(params?.turnStartParams?.threadId)
1781
+ || readString(params?.turn_start_params?.threadId);
1782
+ }
1783
+
1784
+ module.exports = {
1785
+ applyAppServerMessageToConversationState,
1786
+ buildConversationStatePatches,
1787
+ buildConversationStateFromThread,
1788
+ createDesktopIpcLiveOwner,
1789
+ resolveDefaultIpcSocketPath,
1790
+ };