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