@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,1169 @@
1
+ // FILE: desktop-ipc-conversation-projector.js
2
+ // Purpose: Projects Codex Desktop IPC conversation snapshots into app-server-style live notifications.
3
+ // Layer: CLI helper
4
+ // Exports: createDesktopConversationProjector, projectDesktopConversationStateToThread
5
+ // Depends on: ./desktop-ipc-shared
6
+
7
+ const {
8
+ cloneJSON,
9
+ normalizeToken,
10
+ readString,
11
+ readText,
12
+ sanitizeUserInputEntries: sanitizeSharedUserInputEntries,
13
+ } = require("./desktop-ipc-shared");
14
+
15
+ const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
16
+ const MIRROR_TAG = {
17
+ remodexDesktopMirror: true,
18
+ remodexDesktopIpcMirror: true,
19
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
20
+ };
21
+
22
+ // --- Projector lifecycle --------------------------------------
23
+
24
+ // Caches the previous projected Desktop state so raw IPC snapshots/patches become granular mobile events.
25
+ function createDesktopConversationProjector({
26
+ now = () => Date.now(),
27
+ maxCacheSize = 64,
28
+ } = {}) {
29
+ const cacheByThreadId = new Map();
30
+ // Threads whose cache was evicted for size were already mirrored to the phone;
31
+ // re-seeding them as a baseline avoids replaying their whole history again.
32
+ const evictedThreadIds = new Set();
33
+ // The follower applies IPC patches copy-on-write, so raw turn/item objects
34
+ // keep their identity while untouched. Memoizing projections on that identity
35
+ // makes each diff cost O(changed turns) instead of O(whole conversation).
36
+ const projectedTurnsByRawTurn = new WeakMap();
37
+ const projectedItemsByRawItem = new WeakMap();
38
+
39
+ function projectState(threadId, rawState) {
40
+ return projectConversationState(threadId, rawState, {
41
+ now,
42
+ turnCache: projectedTurnsByRawTurn,
43
+ itemCache: projectedItemsByRawItem,
44
+ });
45
+ }
46
+
47
+ function project(threadId, rawState, { includeAllActiveTurns = false } = {}) {
48
+ const normalizedThreadId = readString(threadId);
49
+ if (!normalizedThreadId || !rawState || typeof rawState !== "object") {
50
+ return {
51
+ type: "none",
52
+ notifications: [],
53
+ };
54
+ }
55
+
56
+ const nextProjection = projectState(normalizedThreadId, rawState);
57
+ const previousCache = cacheByThreadId.get(normalizedThreadId) || null;
58
+ const previousProjection = previousCache?.projection || null;
59
+ const continuityMatches = previousProjection
60
+ ? matchDesktopTurnIdentityContinuities(previousProjection.turns, nextProjection.turns)
61
+ : { previousTurnIds: new Set(), nextTurnIds: new Set() };
62
+ const hasSyntheticAliasRepair = continuityMatches.nextTurnIds.size > 0;
63
+ const requiresSyntheticFullReplace = previousProjection
64
+ && hasSynthesizedTurnIds(previousProjection)
65
+ && (!hasSynthesizedTurnIds(nextProjection) || hasSyntheticAliasRepair);
66
+
67
+ let notifications;
68
+ let type = "events";
69
+ let turnIdentityContinuityTurnIds = [];
70
+ if (!previousProjection && evictedThreadIds.has(normalizedThreadId)) {
71
+ // Previously mirrored but evicted: reseed silently so the phone does not
72
+ // receive a duplicate bootstrap replay of already-delivered history.
73
+ evictedThreadIds.delete(normalizedThreadId);
74
+ type = "baseline";
75
+ notifications = [];
76
+ } else if (!previousProjection) {
77
+ notifications = bootstrapNotifications(normalizedThreadId, nextProjection, {
78
+ includeAllActiveTurns,
79
+ });
80
+ } else if (requiresSyntheticFullReplace) {
81
+ type = "fullReplace";
82
+ const unchangedActiveTurnIDs = nextProjection.turns.flatMap((turn) => (
83
+ isActiveTurnStatus(turn.status)
84
+ && previousProjection.turns.some((previousTurn) => previousTurn.id === turn.id)
85
+ ? [turn.id]
86
+ : []
87
+ ));
88
+ turnIdentityContinuityTurnIds = [
89
+ ...continuityMatches.nextTurnIds,
90
+ ...unchangedActiveTurnIDs.filter((turnID) => !continuityMatches.nextTurnIds.has(turnID)),
91
+ ];
92
+ notifications = [
93
+ threadStartedNotification(nextProjection.thread),
94
+ ...bootstrapNotifications(normalizedThreadId, nextProjection, {
95
+ includeThreadStarted: false,
96
+ includeAllActiveTurns: true,
97
+ }),
98
+ ];
99
+ } else {
100
+ notifications = diffProjections(
101
+ normalizedThreadId,
102
+ previousProjection,
103
+ nextProjection
104
+ );
105
+ }
106
+
107
+ cacheByThreadId.set(normalizedThreadId, {
108
+ projection: nextProjection,
109
+ lastUpdated: now(),
110
+ });
111
+ evictOldest();
112
+
113
+ return {
114
+ type,
115
+ notifications,
116
+ thread: nextProjection.thread,
117
+ turnIdentityContinuityTurnIds,
118
+ };
119
+ }
120
+
121
+ // Seeds a baseline without replaying old history; subsequent IPC changes diff against it.
122
+ function seed(threadId, rawState) {
123
+ const normalizedThreadId = readString(threadId);
124
+ if (!normalizedThreadId || !rawState || typeof rawState !== "object") {
125
+ return;
126
+ }
127
+ const projection = projectState(normalizedThreadId, rawState);
128
+ cacheByThreadId.set(normalizedThreadId, {
129
+ projection,
130
+ lastUpdated: now(),
131
+ });
132
+ evictOldest();
133
+ }
134
+
135
+ function remove(threadId) {
136
+ const normalizedThreadId = readString(threadId);
137
+ cacheByThreadId.delete(normalizedThreadId);
138
+ // Explicit removals are semantic (archive, ownership change): a later
139
+ // re-follow of this thread should bootstrap fresh, not stay silent.
140
+ evictedThreadIds.delete(normalizedThreadId);
141
+ }
142
+
143
+ function reset() {
144
+ cacheByThreadId.clear();
145
+ evictedThreadIds.clear();
146
+ }
147
+
148
+ function evictOldest() {
149
+ while (cacheByThreadId.size > maxCacheSize) {
150
+ const oldest = Array.from(cacheByThreadId.entries())
151
+ .sort((left, right) => left[1].lastUpdated - right[1].lastUpdated)[0];
152
+ if (!oldest) {
153
+ return;
154
+ }
155
+ cacheByThreadId.delete(oldest[0]);
156
+ evictedThreadIds.add(oldest[0]);
157
+ }
158
+ }
159
+
160
+ return {
161
+ project,
162
+ seed,
163
+ remove,
164
+ reset,
165
+ };
166
+ }
167
+
168
+ // --- Projection model ------------------------------------------
169
+
170
+ // Converts Desktop's raw conversationState JSON into the thread shape mobile history already parses.
171
+ function projectDesktopConversationStateToThread(threadId, rawState, { now = () => Date.now() } = {}) {
172
+ return projectConversationState(threadId, rawState, { now }).thread;
173
+ }
174
+
175
+ function projectConversationState(threadId, rawState, {
176
+ now = () => Date.now(),
177
+ turnCache = null,
178
+ itemCache = null,
179
+ } = {}) {
180
+ const turns = projectTurns(threadId, rawState, { turnCache, itemCache });
181
+ const activeTurnId = activeTurnIdFromTurns(turns);
182
+ const runtimeSettings = rawState?.remodexRuntimeSettings
183
+ || rawState?.remodex_runtime_settings
184
+ || null;
185
+ const thread = {
186
+ id: threadId,
187
+ sessionId: threadId,
188
+ session_id: threadId,
189
+ title: readString(rawState?.title) || null,
190
+ name: readString(rawState?.title) || null,
191
+ preview: threadPreview(turns),
192
+ createdAt: normalizeTimestamp(rawState?.createdAt ?? rawState?.created_at) || now(),
193
+ updatedAt: normalizeTimestamp(rawState?.updatedAt ?? rawState?.updated_at) || now(),
194
+ cwd: readString(rawState?.cwd) || readString(rawState?.current_working_directory) || "",
195
+ path: readString(rawState?.rolloutPath) || readString(rawState?.rollout_path) || null,
196
+ modelProvider: readString(rawState?.modelProvider) || readString(rawState?.model_provider) || "",
197
+ model: readString(runtimeSettings?.model)
198
+ || readString(rawState?.latestModel)
199
+ || readString(rawState?.latest_model)
200
+ || "",
201
+ ...(runtimeSettings ? {
202
+ reasoningEffort: readString(runtimeSettings.reasoningEffort) || null,
203
+ serviceTier: readString(runtimeSettings.serviceTier) || null,
204
+ runtimeSettingsRevision: Number(runtimeSettings.revision) || 0,
205
+ runtimeSettingsUpdatedAt: Number(runtimeSettings.updatedAt) || 0,
206
+ runtimeSettingsSource: readString(runtimeSettings.source) || null,
207
+ } : {}),
208
+ cliVersion: readString(rawState?.cliVersion) || readString(rawState?.cli_version) || "",
209
+ source: rawState?.source ?? null,
210
+ gitInfo: cloneJSON(rawState?.gitInfo ?? rawState?.git_info ?? null),
211
+ agentNickname: readString(rawState?.agentNickname) || readString(rawState?.agent_nickname) || null,
212
+ agentRole: readString(rawState?.agentRole) || readString(rawState?.agent_role) || null,
213
+ status: resolveThreadStatus(rawState, activeTurnId),
214
+ tokenUsage: cloneJSON(rawState?.latestTokenUsageInfo ?? rawState?.latest_token_usage_info ?? null),
215
+ turns,
216
+ };
217
+
218
+ return {
219
+ thread,
220
+ turns,
221
+ activeTurnId,
222
+ status: thread.status,
223
+ };
224
+ }
225
+
226
+ function projectTurns(threadId, rawState, { turnCache = null, itemCache = null } = {}) {
227
+ const rawTurns = Array.isArray(rawState?.turns) ? rawState.turns : [];
228
+ return rawTurns
229
+ .map((turn, index) => projectTurnCached(threadId, turn, index, { turnCache, itemCache }))
230
+ .filter(Boolean);
231
+ }
232
+
233
+ function projectTurnCached(threadId, rawTurn, index, { turnCache = null, itemCache = null } = {}) {
234
+ if (!rawTurn || typeof rawTurn !== "object") {
235
+ return null;
236
+ }
237
+ // Synthetic ids depend on the array index, so only turns with a real id are
238
+ // safe to reuse by identity.
239
+ const hasStableId = Boolean(readString(rawTurn.turnId) || readString(rawTurn.turn_id) || readString(rawTurn.id));
240
+ if (!turnCache || !hasStableId) {
241
+ return projectTurn(threadId, rawTurn, index, { itemCache });
242
+ }
243
+ const cached = turnCache.get(rawTurn);
244
+ if (cached) {
245
+ return cached;
246
+ }
247
+ const projected = projectTurn(threadId, rawTurn, index, { itemCache });
248
+ if (projected) {
249
+ turnCache.set(rawTurn, projected);
250
+ }
251
+ return projected;
252
+ }
253
+
254
+ function projectTurn(threadId, rawTurn, index, { itemCache = null } = {}) {
255
+ if (!rawTurn || typeof rawTurn !== "object") {
256
+ return null;
257
+ }
258
+ const turnId = readString(rawTurn.turnId)
259
+ || readString(rawTurn.turn_id)
260
+ || readString(rawTurn.id)
261
+ || `ipc-turn-${index}`;
262
+ const status = normalizeTurnStatus(rawTurn.status);
263
+ const paramsInput = Array.isArray(rawTurn?.params?.input) ? cloneJSON(rawTurn.params.input) : [];
264
+ const visibleInput = sanitizeUserInputEntries(paramsInput);
265
+ const items = [];
266
+ if (visibleInput.length > 0) {
267
+ items.push({
268
+ id: `${turnId}:input`,
269
+ type: "userMessage",
270
+ content: visibleInput,
271
+ });
272
+ }
273
+
274
+ for (const item of Array.isArray(rawTurn.items) ? rawTurn.items : []) {
275
+ if (!item || typeof item !== "object") {
276
+ continue;
277
+ }
278
+ const itemType = normalizeToken(item.type);
279
+ if (!isSupportedItemType(itemType)) {
280
+ continue;
281
+ }
282
+ // Desktop stores the prompt both in turn params and as a canonical
283
+ // userMessage item, often with different entry shapes (extra fields,
284
+ // wrapper text). Matching on the sanitized visible text instead of raw
285
+ // JSON equality keeps one user row; the strict shape check alone let the
286
+ // same prompt through twice and duplicated it on the phone via history.
287
+ if (itemType === "usermessage"
288
+ && visibleInput.length > 0
289
+ && (sameUserInput(item.content, paramsInput)
290
+ || sameVisibleUserText(item.content, visibleInput))) {
291
+ continue;
292
+ }
293
+ // Projected items are treated as immutable by every consumer, so raw items
294
+ // that survived copy-on-write untouched can reuse their previous clone.
295
+ const cachedItem = itemCache?.get(item);
296
+ if (cachedItem) {
297
+ if (cachedItem !== SKIPPED_ITEM) {
298
+ items.push(cachedItem);
299
+ }
300
+ continue;
301
+ }
302
+ const projectedItem = projectItemForMobile(item, itemType);
303
+ itemCache?.set(item, projectedItem ?? SKIPPED_ITEM);
304
+ if (projectedItem) {
305
+ items.push(projectedItem);
306
+ }
307
+ }
308
+
309
+ return {
310
+ id: turnId,
311
+ turnId,
312
+ status,
313
+ error: cloneJSON(rawTurn.error || null),
314
+ startedAt: rawTurn.startedAt
315
+ ?? rawTurn.started_at
316
+ ?? rawTurn.turnStartedAtMs
317
+ ?? rawTurn.turn_started_at_ms
318
+ ?? null,
319
+ completedAt: rawTurn.completedAt ?? rawTurn.completed_at ?? null,
320
+ durationMs: rawTurn.durationMs ?? rawTurn.duration_ms ?? null,
321
+ items,
322
+ };
323
+ }
324
+
325
+ // --- Notification generation ----------------------------------
326
+
327
+ function bootstrapNotifications(
328
+ threadId,
329
+ projection,
330
+ { includeThreadStarted = true, includeAllActiveTurns = false } = {}
331
+ ) {
332
+ const notifications = includeThreadStarted && shouldEmitThreadStarted(projection.thread)
333
+ ? [threadStartedNotification(projection.thread)]
334
+ : [];
335
+ const activeTurns = includeAllActiveTurns
336
+ ? projection.turns.filter((turn) => isActiveTurnStatus(turn.status))
337
+ : [projection.activeTurnId
338
+ ? projection.turns.find((turn) => turn.id === projection.activeTurnId)
339
+ : null].filter(Boolean);
340
+ if (activeTurns.length === 0) {
341
+ return notifications;
342
+ }
343
+
344
+ for (const activeTurn of activeTurns) {
345
+ notifications.push(turnStartedNotification(threadId, activeTurn));
346
+ for (const item of activeTurn.items) {
347
+ notifications.push(itemStartedNotification(threadId, activeTurn.id, item));
348
+ // Streaming items (running commands, in-flight tools) must not be closed
349
+ // prematurely; later diffs emit their deltas and eventual completion.
350
+ if (isTerminalItemState(item)) {
351
+ notifications.push(itemCompletedNotification(threadId, activeTurn.id, item));
352
+ }
353
+ }
354
+ }
355
+ return notifications;
356
+ }
357
+
358
+ function shouldEmitThreadStarted(thread) {
359
+ return Boolean(readString(thread.title)
360
+ || readString(thread.name)
361
+ || readString(thread.cwd)
362
+ || readString(thread.preview)
363
+ || (Array.isArray(thread.turns) && thread.turns.length > 0));
364
+ }
365
+
366
+ function diffProjections(threadId, previousProjection, nextProjection) {
367
+ const notifications = [];
368
+
369
+ notifications.push(...diffThreadMetadata(previousProjection.thread, nextProjection.thread));
370
+ notifications.push(...diffTurnLifecycle(threadId, previousProjection, nextProjection));
371
+ notifications.push(...diffTurnItems(threadId, previousProjection, nextProjection));
372
+
373
+ return notifications;
374
+ }
375
+
376
+ function diffThreadMetadata(previousThread, nextThread) {
377
+ const notifications = [];
378
+ const previousRuntimeRevision = Number(previousThread.runtimeSettingsRevision) || 0;
379
+ const nextRuntimeRevision = Number(nextThread.runtimeSettingsRevision) || 0;
380
+ if (nextRuntimeRevision > previousRuntimeRevision) {
381
+ notifications.push(threadStartedNotification(nextThread));
382
+ }
383
+ const previousTitle = readString(previousThread.title) || readString(previousThread.name);
384
+ const nextTitle = readString(nextThread.title) || readString(nextThread.name);
385
+ if (nextTitle && previousTitle !== nextTitle) {
386
+ notifications.push(tagNotification({
387
+ method: "thread/name/updated",
388
+ params: {
389
+ threadId: nextThread.id,
390
+ threadName: nextTitle,
391
+ name: nextTitle,
392
+ title: nextTitle,
393
+ },
394
+ }));
395
+ }
396
+
397
+ if (JSON.stringify(previousThread.status || null) !== JSON.stringify(nextThread.status || null)) {
398
+ notifications.push(tagNotification({
399
+ method: "thread/status/changed",
400
+ params: {
401
+ threadId: nextThread.id,
402
+ status: cloneJSON(nextThread.status || null),
403
+ },
404
+ }));
405
+ }
406
+
407
+ if (nextThread.tokenUsage != null
408
+ && JSON.stringify(previousThread.tokenUsage || null) !== JSON.stringify(nextThread.tokenUsage)) {
409
+ notifications.push(tagNotification({
410
+ method: "thread/tokenUsage/updated",
411
+ params: {
412
+ threadId: nextThread.id,
413
+ usage: cloneJSON(nextThread.tokenUsage),
414
+ tokenUsage: cloneJSON(nextThread.tokenUsage),
415
+ },
416
+ }));
417
+ }
418
+
419
+ return notifications;
420
+ }
421
+
422
+ function diffTurnLifecycle(threadId, previousProjection, nextProjection) {
423
+ const notifications = [];
424
+ const previousActive = previousProjection.activeTurnId || null;
425
+ const nextActive = nextProjection.activeTurnId || null;
426
+ if (!previousActive && nextActive) {
427
+ const nextTurn = findTurn(nextProjection.turns, nextActive);
428
+ if (nextTurn) {
429
+ notifications.push(turnStartedNotification(threadId, nextTurn));
430
+ }
431
+ return notifications;
432
+ }
433
+ if (previousActive && !nextActive) {
434
+ const completedTurn = findTurn(nextProjection.turns, previousActive)
435
+ || findTurn(previousProjection.turns, previousActive);
436
+ if (completedTurn) {
437
+ notifications.push(turnCompletedNotification(threadId, completedTurn));
438
+ }
439
+ return notifications;
440
+ }
441
+ if (previousActive && nextActive && previousActive !== nextActive) {
442
+ const completedTurn = findTurn(nextProjection.turns, previousActive)
443
+ || findTurn(previousProjection.turns, previousActive);
444
+ const startedTurn = findTurn(nextProjection.turns, nextActive);
445
+ if (completedTurn) {
446
+ notifications.push(turnCompletedNotification(threadId, completedTurn));
447
+ }
448
+ if (startedTurn) {
449
+ notifications.push(turnStartedNotification(threadId, startedTurn));
450
+ }
451
+ }
452
+ return notifications;
453
+ }
454
+
455
+ function diffTurnItems(threadId, previousProjection, nextProjection) {
456
+ const notifications = [];
457
+ const previousTurnsById = new Map(previousProjection.turns.map((turn) => [turn.id, turn]));
458
+
459
+ for (const nextTurn of nextProjection.turns) {
460
+ const previousTurn = previousTurnsById.get(nextTurn.id) || null;
461
+ // Memoized projections keep their identity when the raw turn survived
462
+ // copy-on-write untouched, so unchanged turns cost nothing to skip.
463
+ if (previousTurn === nextTurn) {
464
+ continue;
465
+ }
466
+ const previousItemsById = new Map((previousTurn?.items || []).map((item) => [itemIdOf(item), item]));
467
+ const isActiveTurn = nextProjection.activeTurnId === nextTurn.id;
468
+
469
+ for (const nextItem of nextTurn.items) {
470
+ const itemId = itemIdOf(nextItem);
471
+ if (!itemId) {
472
+ continue;
473
+ }
474
+ const previousItem = previousItemsById.get(itemId) || null;
475
+ if (!previousItem) {
476
+ notifications.push(itemStartedNotification(threadId, nextTurn.id, nextItem));
477
+ // Only close items that are actually finished; a newly observed running
478
+ // item keeps streaming through later diffs on the active turn.
479
+ if (!isActiveTurn || isTerminalItemState(nextItem)) {
480
+ notifications.push(itemCompletedNotification(threadId, nextTurn.id, nextItem));
481
+ }
482
+ continue;
483
+ }
484
+ if (previousItem === nextItem
485
+ || JSON.stringify(previousItem) === JSON.stringify(nextItem)) {
486
+ continue;
487
+ }
488
+ if (!isActiveTurn) {
489
+ notifications.push(itemCompletedNotification(threadId, nextTurn.id, nextItem));
490
+ continue;
491
+ }
492
+ notifications.push(...diffItem(threadId, nextTurn.id, previousItem, nextItem));
493
+ }
494
+ }
495
+
496
+ return notifications;
497
+ }
498
+
499
+ function diffItem(threadId, turnId, previousItem, nextItem) {
500
+ const itemId = itemIdOf(nextItem);
501
+ const itemType = normalizeToken(nextItem.type);
502
+ // Previous text lengths come straight from the previous projection, which is
503
+ // exactly what the per-thread snapshot map used to store.
504
+ const snapshot = snapshotItem(previousItem);
505
+ if (isAssistantMessageItem(nextItem)) {
506
+ const previousText = assistantMessageText(previousItem);
507
+ const nextText = assistantMessageText(nextItem);
508
+ const delta = appendedDelta(previousText, nextText, snapshot.agentTextLen);
509
+ if (delta) {
510
+ return [deltaNotification("item/agentMessage/delta", threadId, turnId, itemId, delta)];
511
+ }
512
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
513
+ }
514
+
515
+ if (itemType === "plan" || itemType === "todolist") {
516
+ const previousText = planText(previousItem);
517
+ const nextText = planText(nextItem);
518
+ const delta = appendedDelta(previousText, nextText, snapshot.planTextLen);
519
+ if (delta) {
520
+ return [deltaNotification("item/plan/delta", threadId, turnId, itemId, delta)];
521
+ }
522
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
523
+ }
524
+
525
+ if (itemType === "reasoning") {
526
+ const reasoning = reasoningDeltaNotifications(threadId, turnId, itemId, previousItem, nextItem, snapshot);
527
+ return reasoning.length > 0 ? reasoning : [itemCompletedNotification(threadId, turnId, nextItem)];
528
+ }
529
+
530
+ if (itemType === "commandexecution") {
531
+ if (normalizeToken(previousItem.status) !== normalizeToken(nextItem.status)) {
532
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
533
+ }
534
+ const delta = appendedDelta(commandOutput(previousItem), commandOutput(nextItem), snapshot.commandOutputLen);
535
+ if (delta) {
536
+ return [deltaNotification("item/commandExecution/outputDelta", threadId, turnId, itemId, delta)];
537
+ }
538
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
539
+ }
540
+
541
+ if (itemType === "filechange") {
542
+ const delta = appendedDelta(fileChangeOutput(previousItem), fileChangeOutput(nextItem), snapshot.fileOutputLen);
543
+ if (delta) {
544
+ return [deltaNotification("item/fileChange/outputDelta", threadId, turnId, itemId, delta)];
545
+ }
546
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
547
+ }
548
+
549
+ if (isToolCallItem(nextItem)) {
550
+ const delta = appendedDelta(toolCallOutput(previousItem), toolCallOutput(nextItem), snapshot.toolOutputLen);
551
+ if (delta) {
552
+ return [deltaNotification("item/toolCall/outputDelta", threadId, turnId, itemId, delta, {
553
+ item: cloneJSON(nextItem),
554
+ })];
555
+ }
556
+ }
557
+
558
+ return [itemCompletedNotification(threadId, turnId, nextItem)];
559
+ }
560
+
561
+ function reasoningDeltaNotifications(threadId, turnId, itemId, previousItem, nextItem, snapshot) {
562
+ const notifications = [];
563
+ const previousSummary = textArray(previousItem.summary);
564
+ const nextSummary = textArray(nextItem.summary);
565
+ const previousContent = textArray(previousItem.content);
566
+ const nextContent = textArray(nextItem.content);
567
+ const summaryLens = Array.isArray(snapshot.reasoningSummaryLens) ? snapshot.reasoningSummaryLens : [];
568
+ const contentLens = Array.isArray(snapshot.reasoningContentLens) ? snapshot.reasoningContentLens : [];
569
+
570
+ for (let index = 0; index < nextSummary.length; index += 1) {
571
+ if (index >= previousSummary.length) {
572
+ notifications.push(tagNotification({
573
+ method: "item/reasoning/summaryPartAdded",
574
+ params: {
575
+ threadId,
576
+ turnId,
577
+ itemId,
578
+ summaryIndex: index,
579
+ },
580
+ }));
581
+ if (nextSummary[index]) {
582
+ notifications.push(deltaNotification(
583
+ "item/reasoning/summaryTextDelta",
584
+ threadId,
585
+ turnId,
586
+ itemId,
587
+ nextSummary[index],
588
+ { summaryIndex: index }
589
+ ));
590
+ }
591
+ continue;
592
+ }
593
+ const delta = appendedDelta(previousSummary[index], nextSummary[index], summaryLens[index]);
594
+ if (delta) {
595
+ notifications.push(deltaNotification(
596
+ "item/reasoning/summaryTextDelta",
597
+ threadId,
598
+ turnId,
599
+ itemId,
600
+ delta,
601
+ { summaryIndex: index }
602
+ ));
603
+ }
604
+ }
605
+
606
+ for (let index = 0; index < nextContent.length; index += 1) {
607
+ const previousText = previousContent[index] || "";
608
+ const delta = appendedDelta(previousText, nextContent[index], contentLens[index]);
609
+ if (delta) {
610
+ notifications.push(deltaNotification(
611
+ "item/reasoning/textDelta",
612
+ threadId,
613
+ turnId,
614
+ itemId,
615
+ delta,
616
+ { contentIndex: index }
617
+ ));
618
+ }
619
+ }
620
+
621
+ return notifications;
622
+ }
623
+
624
+ // --- Notification shapes --------------------------------------
625
+
626
+ function threadStartedNotification(thread) {
627
+ return tagNotification({
628
+ method: "thread/started",
629
+ params: {
630
+ threadId: thread.id,
631
+ thread: cloneJSON(thread),
632
+ },
633
+ });
634
+ }
635
+
636
+ function turnStartedNotification(threadId, turn) {
637
+ return tagNotification({
638
+ method: "turn/started",
639
+ params: {
640
+ threadId,
641
+ turnId: turn.id,
642
+ turn: cloneJSON(turn),
643
+ },
644
+ });
645
+ }
646
+
647
+ function turnCompletedNotification(threadId, turn) {
648
+ return tagNotification({
649
+ method: "turn/completed",
650
+ params: {
651
+ threadId,
652
+ turnId: turn.id,
653
+ turn: cloneJSON(turn),
654
+ status: turn.status,
655
+ error: cloneJSON(turn.error || null),
656
+ },
657
+ });
658
+ }
659
+
660
+ function itemStartedNotification(threadId, turnId, item) {
661
+ return tagNotification({
662
+ method: "item/started",
663
+ params: {
664
+ threadId,
665
+ turnId,
666
+ itemId: itemIdOf(item),
667
+ item: cloneJSON(item),
668
+ },
669
+ });
670
+ }
671
+
672
+ function itemCompletedNotification(threadId, turnId, item) {
673
+ return tagNotification({
674
+ method: "item/completed",
675
+ params: {
676
+ threadId,
677
+ turnId,
678
+ itemId: itemIdOf(item),
679
+ item: cloneJSON(item),
680
+ },
681
+ });
682
+ }
683
+
684
+ function deltaNotification(method, threadId, turnId, itemId, delta, extraParams = {}) {
685
+ return tagNotification({
686
+ method,
687
+ params: {
688
+ threadId,
689
+ turnId,
690
+ itemId,
691
+ delta,
692
+ ...extraParams,
693
+ },
694
+ });
695
+ }
696
+
697
+ function tagNotification(notification) {
698
+ return {
699
+ method: notification.method,
700
+ params: {
701
+ ...notification.params,
702
+ ...MIRROR_TAG,
703
+ },
704
+ };
705
+ }
706
+
707
+ // --- Text snapshots -------------------------------------------
708
+
709
+ function snapshotItem(item) {
710
+ return {
711
+ agentTextLen: assistantMessageText(item).length,
712
+ planTextLen: planText(item).length,
713
+ reasoningSummaryLens: textArray(item.summary).map((entry) => entry.length),
714
+ reasoningContentLens: textArray(item.content).map((entry) => entry.length),
715
+ commandOutputLen: commandOutput(item).length,
716
+ fileOutputLen: fileChangeOutput(item).length,
717
+ toolOutputLen: toolCallOutput(item).length,
718
+ };
719
+ }
720
+
721
+ function appendedDelta(previousText, nextText, snapshotLength) {
722
+ const normalizedPrevious = typeof previousText === "string" ? previousText : "";
723
+ const normalizedNext = typeof nextText === "string" ? nextText : "";
724
+ const previousLength = Number.isInteger(snapshotLength) ? snapshotLength : normalizedPrevious.length;
725
+ const prefix = normalizedPrevious.slice(0, Math.min(previousLength, normalizedPrevious.length));
726
+ if (!normalizedNext.startsWith(prefix) || normalizedNext.length <= previousLength) {
727
+ return "";
728
+ }
729
+ return normalizedNext.slice(previousLength);
730
+ }
731
+
732
+ // --- Shape helpers --------------------------------------------
733
+
734
+ function activeTurnIdFromTurns(turns) {
735
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
736
+ const turn = turns[index];
737
+ if (isActiveTurnStatus(turn.status)) {
738
+ return turn.id;
739
+ }
740
+ }
741
+ return null;
742
+ }
743
+
744
+ function resolveThreadStatus(rawState, activeTurnId) {
745
+ const explicitStatus = rawState?.threadRuntimeStatus ?? rawState?.thread_runtime_status;
746
+ if (explicitStatus != null) {
747
+ return cloneJSON(explicitStatus);
748
+ }
749
+ if (activeTurnId) {
750
+ return {
751
+ type: "active",
752
+ activeFlags: [],
753
+ };
754
+ }
755
+ return {
756
+ type: "idle",
757
+ };
758
+ }
759
+
760
+ function normalizeTurnStatus(value) {
761
+ const token = normalizeToken(value);
762
+ if (token === "inprogress" || token === "running" || token === "active" || token === "processing") {
763
+ return "inProgress";
764
+ }
765
+ if (token === "interrupted" || token === "cancelled" || token === "canceled" || token === "stopped") {
766
+ return "interrupted";
767
+ }
768
+ if (token === "failed" || token === "error" || token === "systemerror") {
769
+ return "failed";
770
+ }
771
+ return "completed";
772
+ }
773
+
774
+ function isActiveTurnStatus(value) {
775
+ return normalizeToken(value) === "inprogress"
776
+ || normalizeToken(value) === "running"
777
+ || normalizeToken(value) === "active"
778
+ || normalizeToken(value) === "processing";
779
+ }
780
+
781
+ // Items without an explicit status (messages, reasoning) are complete as
782
+ // delivered; only explicitly running items must stay open for streaming.
783
+ function isTerminalItemState(item) {
784
+ const status = normalizeToken(item?.status);
785
+ if (!status) {
786
+ return true;
787
+ }
788
+ return status !== "inprogress"
789
+ && status !== "running"
790
+ && status !== "active"
791
+ && status !== "processing"
792
+ && status !== "pending"
793
+ && status !== "queued";
794
+ }
795
+
796
+ function hasSynthesizedTurnIds(projection) {
797
+ return projection.turns.some((turn) => readString(turn.id).startsWith("ipc-turn-"));
798
+ }
799
+
800
+ // Synthetic Desktop ids can become canonical without starting a new logical
801
+ // turn. Require stable content identity so a disconnected A -> different B
802
+ // snapshot is not mistaken for a mere id repair.
803
+ function desktopTurnsShareLogicalIdentity(previousTurn, nextTurn) {
804
+ return desktopTurnLogicalIdentityScore(previousTurn, nextTurn) > 0;
805
+ }
806
+
807
+ function desktopTurnLogicalIdentityScore(previousTurn, nextTurn) {
808
+ if (!previousTurn || !nextTurn) {
809
+ return 0;
810
+ }
811
+
812
+ const previousStableItemIDs = stableTurnItemIDs(previousTurn);
813
+ const nextStableItemIDs = stableTurnItemIDs(nextTurn);
814
+ for (const itemID of previousStableItemIDs) {
815
+ if (nextStableItemIDs.has(itemID)) {
816
+ return 2;
817
+ }
818
+ }
819
+
820
+ const previousPrompt = turnPromptSignature(previousTurn);
821
+ const nextPrompt = turnPromptSignature(nextTurn);
822
+ const previousStart = turnStartIdentity(previousTurn);
823
+ const nextStart = turnStartIdentity(nextTurn);
824
+ return previousPrompt !== ""
825
+ && previousPrompt === nextPrompt
826
+ && previousStart !== ""
827
+ && previousStart === nextStart
828
+ ? 1
829
+ : 0;
830
+ }
831
+
832
+ // Matches removed synthetic aliases to added canonical turns one-to-one. Stable
833
+ // item identity wins over the prompt+start fallback, and no canonical turn can
834
+ // suppress more than one real run-start generation.
835
+ function matchDesktopTurnIdentityContinuities(previousTurns, nextTurns) {
836
+ const previousById = new Map(previousTurns.map((turn) => [readString(turn?.id), turn]));
837
+ const nextById = new Map(nextTurns.map((turn) => [readString(turn?.id), turn]));
838
+ const previousTurnIds = new Set();
839
+ const nextTurnIds = new Set();
840
+ const removedSyntheticTurns = previousTurns.filter((turn) => {
841
+ const turnID = readString(turn?.id);
842
+ return turnID && !nextById.has(turnID) && turnID.startsWith("ipc-turn-");
843
+ });
844
+ const addedCanonicalTurns = nextTurns.filter((turn) => {
845
+ const turnID = readString(turn?.id);
846
+ return turnID && !previousById.has(turnID) && !turnID.startsWith("ipc-turn-");
847
+ });
848
+
849
+ function applyMaximumMatchesForScore(requiredScore) {
850
+ const nextOwnerByID = new Map();
851
+
852
+ function tryAssign(previousEntry, visitedNextIDs) {
853
+ for (const nextEntry of addedCanonicalTurns) {
854
+ const nextID = readString(nextEntry?.id);
855
+ if (nextTurnIds.has(nextID) || visitedNextIDs.has(nextID)) {
856
+ continue;
857
+ }
858
+ const score = desktopTurnLogicalIdentityScore(
859
+ previousEntry?.turn || previousEntry,
860
+ nextEntry?.turn || nextEntry
861
+ );
862
+ if (score !== requiredScore) {
863
+ continue;
864
+ }
865
+ visitedNextIDs.add(nextID);
866
+ const currentOwner = nextOwnerByID.get(nextID);
867
+ if (!currentOwner || tryAssign(currentOwner, visitedNextIDs)) {
868
+ nextOwnerByID.set(nextID, previousEntry);
869
+ return true;
870
+ }
871
+ }
872
+ return false;
873
+ }
874
+
875
+ for (const previousEntry of removedSyntheticTurns) {
876
+ const previousID = readString(previousEntry?.id);
877
+ if (!previousTurnIds.has(previousID)) {
878
+ tryAssign(previousEntry, new Set());
879
+ }
880
+ }
881
+ for (const [nextID, previousEntry] of nextOwnerByID) {
882
+ previousTurnIds.add(readString(previousEntry?.id));
883
+ nextTurnIds.add(nextID);
884
+ }
885
+ }
886
+
887
+ // Stable item identity is globally reserved first; prompt+start matching can
888
+ // only consume aliases/canonical turns left over from that maximum matching.
889
+ applyMaximumMatchesForScore(2);
890
+ applyMaximumMatchesForScore(1);
891
+
892
+ return { previousTurnIds, nextTurnIds };
893
+ }
894
+
895
+ function stableTurnItemIDs(turn) {
896
+ return new Set((Array.isArray(turn?.items) ? turn.items : []).flatMap((item) => {
897
+ if (normalizeToken(item?.type) === "usermessage") {
898
+ return [];
899
+ }
900
+ const itemID = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
901
+ return itemID ? [itemID] : [];
902
+ }));
903
+ }
904
+
905
+ function turnPromptSignature(turn) {
906
+ const paramsInput = Array.isArray(turn?.params?.input) ? turn.params.input : [];
907
+ let prompt = renderUserInputText(paramsInput);
908
+ if (!prompt) {
909
+ const userItem = (Array.isArray(turn?.items) ? turn.items : []).find((item) => (
910
+ normalizeToken(item?.type) === "usermessage"
911
+ ));
912
+ prompt = renderUserInputText(userItem?.content);
913
+ }
914
+ return prompt.trim().replace(/\s+/g, " ");
915
+ }
916
+
917
+ function turnStartIdentity(turn) {
918
+ const value = turn?.startedAt
919
+ ?? turn?.started_at
920
+ ?? turn?.turnStartedAtMs
921
+ ?? turn?.turn_started_at_ms;
922
+ if (typeof value === "number" && Number.isFinite(value)) {
923
+ return `number:${value}`;
924
+ }
925
+ const text = readString(value);
926
+ return text ? `text:${text}` : "";
927
+ }
928
+
929
+ function findTurn(turns, turnId) {
930
+ return turns.find((turn) => turn.id === turnId) || null;
931
+ }
932
+
933
+ function threadPreview(turns) {
934
+ for (const turn of turns) {
935
+ for (const item of turn.items) {
936
+ if (normalizeToken(item.type) !== "usermessage") {
937
+ continue;
938
+ }
939
+ const text = renderUserInputText(item.content).trim();
940
+ if (text) {
941
+ return stripRequestWrapper(text);
942
+ }
943
+ }
944
+ }
945
+ return "";
946
+ }
947
+
948
+ function renderUserInputText(content) {
949
+ if (typeof content === "string") {
950
+ return content;
951
+ }
952
+ if (!Array.isArray(content)) {
953
+ return "";
954
+ }
955
+ return content
956
+ .map((entry) => {
957
+ if (typeof entry === "string") {
958
+ return entry;
959
+ }
960
+ if (!entry || typeof entry !== "object") {
961
+ return "";
962
+ }
963
+ return readString(entry.text) || readString(entry.content) || "";
964
+ })
965
+ .filter(Boolean)
966
+ .join("\n");
967
+ }
968
+
969
+ function stripRequestWrapper(text) {
970
+ const marker = "## My request for Codex:";
971
+ return text.includes(marker) ? text.split(marker).at(-1).trim() : text.trim();
972
+ }
973
+
974
+ function sameUserInput(content, paramsInput) {
975
+ return JSON.stringify(content || []) === JSON.stringify(paramsInput || []);
976
+ }
977
+
978
+ function sameVisibleUserText(content, visibleInput) {
979
+ const itemText = renderUserInputText(sanitizeUserInputEntries(content)).trim();
980
+ const paramsText = renderUserInputText(visibleInput).trim();
981
+ return Boolean(itemText) && itemText === paramsText;
982
+ }
983
+
984
+ // Sentinel so the item cache can also remember "this raw item projects to nothing".
985
+ const SKIPPED_ITEM = Symbol("remodex-skipped-item");
986
+
987
+ // Drops injected context fragments (AGENTS.md instructions, IDE wrappers) and
988
+ // strips prompt-request wrappers so only the real user request reaches mobile.
989
+ function sanitizeUserInputEntries(entries) {
990
+ return sanitizeSharedUserInputEntries(entries);
991
+ }
992
+
993
+ // Desktop IPC uses richer tool aliases than the mobile app-server timeline.
994
+ // Keep the original type as metadata, but emit the generic shape iOS already decodes.
995
+ // Returns null when the item has nothing user-visible left after sanitizing.
996
+ function projectItemForMobile(item, itemType = normalizeToken(item?.type)) {
997
+ if (itemType === "usermessage") {
998
+ const visibleContent = sanitizeUserInputEntries(
999
+ Array.isArray(item?.content) ? item.content : []
1000
+ );
1001
+ if (visibleContent.length === 0) {
1002
+ return null;
1003
+ }
1004
+ const projected = cloneJSON(item);
1005
+ projected.content = cloneJSON(visibleContent);
1006
+ return projected;
1007
+ }
1008
+
1009
+ const projected = cloneJSON(item);
1010
+ // Desktop/Litter stores internal update_plan snapshots as todo-list items.
1011
+ // They are progress state, not user-actionable proposed-plan results. Preserve
1012
+ // that semantic across both thread/read projection and lifecycle notifications.
1013
+ if (itemType === "todolist") {
1014
+ projected.remodexProgressPlan = true;
1015
+ }
1016
+ if (!isGenericToolCallItemType(itemType)) {
1017
+ return projected;
1018
+ }
1019
+
1020
+ projected.type = "toolCall";
1021
+ if (!projected.remodexDesktopIpcItemType) {
1022
+ projected.remodexDesktopIpcItemType = readString(item?.type) || itemType;
1023
+ }
1024
+ return projected;
1025
+ }
1026
+
1027
+ function isSupportedItemType(type) {
1028
+ return type === "usermessage"
1029
+ || type === "hookprompt"
1030
+ || type === "agentmessage"
1031
+ || type === "assistantmessage"
1032
+ || type === "message"
1033
+ || type === "plan"
1034
+ || type === "todolist"
1035
+ || type === "reasoning"
1036
+ || type === "commandexecution"
1037
+ || type === "filechange"
1038
+ || type === "toolcall"
1039
+ || type === "mcptoolcall"
1040
+ || type === "dynamictoolcall"
1041
+ || type === "collabagenttoolcall"
1042
+ || type === "collabtoolcall"
1043
+ || type === "websearch"
1044
+ || type === "imageview"
1045
+ || type === "imagegeneration"
1046
+ || type === "enteredreviewmode"
1047
+ || type === "exitedreviewmode"
1048
+ || type === "contextcompaction";
1049
+ }
1050
+
1051
+ function isAssistantMessageItem(item) {
1052
+ const type = normalizeToken(item?.type);
1053
+ if (type === "agentmessage" || type === "assistantmessage") {
1054
+ return true;
1055
+ }
1056
+ return type === "message" && normalizeToken(item?.role) !== "user";
1057
+ }
1058
+
1059
+ function isToolCallItem(item) {
1060
+ const type = normalizeToken(item?.type);
1061
+ return isGenericToolCallItemType(type)
1062
+ || type === "collabagenttoolcall"
1063
+ || type === "collabtoolcall";
1064
+ }
1065
+
1066
+ function isGenericToolCallItemType(type) {
1067
+ return type === "toolcall"
1068
+ || type === "mcptoolcall"
1069
+ || type === "dynamictoolcall"
1070
+ || type === "websearch";
1071
+ }
1072
+
1073
+ function assistantMessageText(item) {
1074
+ if (!isAssistantMessageItem(item)) {
1075
+ return "";
1076
+ }
1077
+ return readText(item.text)
1078
+ || readText(item.message)
1079
+ || renderContentText(item.content);
1080
+ }
1081
+
1082
+ function planText(item) {
1083
+ return readText(item.text)
1084
+ || renderContentText(item.plan)
1085
+ || renderContentText(item.content);
1086
+ }
1087
+
1088
+ function commandOutput(item) {
1089
+ return readText(item.aggregatedOutput)
1090
+ || readText(item.aggregated_output)
1091
+ || readText(item.output)
1092
+ || readText(item.stdout)
1093
+ || "";
1094
+ }
1095
+
1096
+ function fileChangeOutput(item) {
1097
+ return readText(item.aggregatedOutput)
1098
+ || readText(item.aggregated_output)
1099
+ || readText(item.output)
1100
+ || readText(item.diff)
1101
+ || readText(item.patch)
1102
+ || "";
1103
+ }
1104
+
1105
+ function toolCallOutput(item) {
1106
+ return readText(item.output)
1107
+ || readText(item.result)
1108
+ || readText(item.response)
1109
+ || renderContentText(item.result?.content)
1110
+ || renderContentText(item.contentItems)
1111
+ || renderContentText(item.content_items)
1112
+ || renderContentText(item.content)
1113
+ || "";
1114
+ }
1115
+
1116
+ function textArray(value) {
1117
+ if (!Array.isArray(value)) {
1118
+ return [];
1119
+ }
1120
+ return value.map((entry) => {
1121
+ if (typeof entry === "string") {
1122
+ return entry;
1123
+ }
1124
+ if (!entry || typeof entry !== "object") {
1125
+ return "";
1126
+ }
1127
+ return readText(entry.text) || readText(entry.content) || "";
1128
+ });
1129
+ }
1130
+
1131
+ function renderContentText(value) {
1132
+ if (typeof value === "string") {
1133
+ return value;
1134
+ }
1135
+ if (!Array.isArray(value)) {
1136
+ return "";
1137
+ }
1138
+ return value
1139
+ .map((entry) => {
1140
+ if (typeof entry === "string") {
1141
+ return entry;
1142
+ }
1143
+ if (!entry || typeof entry !== "object") {
1144
+ return "";
1145
+ }
1146
+ return readText(entry.text)
1147
+ || readText(entry.content)
1148
+ || readText(entry?.data?.text)
1149
+ || readText(entry?.file?.content);
1150
+ })
1151
+ .filter((entry) => entry !== "")
1152
+ .join("");
1153
+ }
1154
+
1155
+ function itemIdOf(item) {
1156
+ return readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
1157
+ }
1158
+
1159
+ function normalizeTimestamp(value) {
1160
+ const numeric = Number(value);
1161
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
1162
+ }
1163
+
1164
+ module.exports = {
1165
+ createDesktopConversationProjector,
1166
+ desktopTurnsShareLogicalIdentity,
1167
+ matchDesktopTurnIdentityContinuities,
1168
+ projectDesktopConversationStateToThread,
1169
+ };