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