@makerbi/remodex 2.0.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/remodex.js +1 -1
- package/package.json +2 -2
- package/src/account-status.js +5 -4
- package/src/bridge.js +1809 -689
- package/src/codex-desktop-refresher.js +35 -7
- package/src/cursor-acp-client.js +242 -0
- package/src/cursor-models.js +134 -0
- package/src/cursor-provider.js +1197 -0
- package/src/desktop-ipc-action-follower.js +2323 -123
- package/src/desktop-ipc-conversation-adapter.js +1132 -0
- package/src/desktop-ipc-conversation-projector.js +1169 -0
- package/src/desktop-ipc-live-owner.js +1790 -0
- package/src/desktop-ipc-owner-transport.js +750 -0
- package/src/desktop-ipc-shared.js +473 -0
- package/src/desktop-ipc-state-patches.js +218 -0
- package/src/opencode-models.js +108 -0
- package/src/opencode-provider.js +1151 -0
- package/src/project-handler.js +50 -7
- package/src/project-registry.js +466 -0
- package/src/push-notification-tracker.js +4 -4
- package/src/rollout-live-mirror.js +946 -78
- package/src/rollout-turn-semantics.js +20 -0
- package/src/runtime-provider-models.js +164 -0
- package/src/runtime-provider-router.js +365 -0
- package/src/scripts/codex-refresh.applescript +26 -15
- package/src/secure-transport.js +204 -9
- package/src/session-jsonl-history.js +429 -39
- package/src/thread-context-handler.js +8 -6
- package/src/thread-runtime-settings-store.js +247 -0
- package/src/voice-audio.js +344 -0
- package/src/voice-handler.js +363 -173
package/src/bridge.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// Depends on: ws, crypto, os, ./bridge-status, ./codex-desktop-refresher, ./codex-transport, ./rollout-watch, ./voice-handler
|
|
6
6
|
|
|
7
7
|
const WebSocket = require("ws");
|
|
8
|
-
const {
|
|
8
|
+
const { constants: bufferConstants } = require("buffer");
|
|
9
|
+
const { createHash, randomBytes } = require("crypto");
|
|
9
10
|
const { execFile, spawn } = require("child_process");
|
|
10
11
|
const fs = require("fs");
|
|
11
12
|
const path = require("path");
|
|
@@ -52,10 +53,19 @@ const {
|
|
|
52
53
|
} = require("./secure-device-state");
|
|
53
54
|
const { createBridgeSecureTransport } = require("./secure-transport");
|
|
54
55
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
56
|
+
const {
|
|
57
|
+
isContextualUserText,
|
|
58
|
+
isUserRoleItem,
|
|
59
|
+
readUserItemText,
|
|
60
|
+
sanitizeUserRoleItem,
|
|
61
|
+
visibleUserPromptText,
|
|
62
|
+
} = require("./desktop-ipc-shared");
|
|
55
63
|
const {
|
|
56
64
|
createDesktopIpcActionFollower,
|
|
57
65
|
seedConversationStateFromThreadRead,
|
|
58
66
|
} = require("./desktop-ipc-action-follower");
|
|
67
|
+
const { createDesktopIpcLiveOwner } = require("./desktop-ipc-live-owner");
|
|
68
|
+
const { createThreadRuntimeSettingsStore } = require("./thread-runtime-settings-store");
|
|
59
69
|
const { version: bridgePackageVersion = "" } = require("../package.json");
|
|
60
70
|
const {
|
|
61
71
|
MINIMUM_SUPPORTED_IOS_APP_VERSION,
|
|
@@ -65,21 +75,29 @@ const {
|
|
|
65
75
|
} = require("./ios-app-compatibility");
|
|
66
76
|
const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
|
|
67
77
|
const {
|
|
68
|
-
|
|
78
|
+
JSONL_OLDER_HANDOFF_CURSOR,
|
|
69
79
|
parseSessionJsonlTurns,
|
|
80
|
+
readRecentSessionJsonlTurns,
|
|
81
|
+
readSessionJsonlMetadataFromFile,
|
|
70
82
|
readThreadTurnsListPageFromSessionJsonl,
|
|
71
83
|
} = require("./session-jsonl-history");
|
|
72
84
|
const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
|
|
85
|
+
const {
|
|
86
|
+
createRuntimeProviderRouter,
|
|
87
|
+
stripRuntimeProviderFieldsForCodex,
|
|
88
|
+
} = require("./runtime-provider-router");
|
|
89
|
+
const { createProjectRegistry } = require("./project-registry");
|
|
73
90
|
|
|
74
91
|
const execFileAsync = promisify(execFile);
|
|
75
92
|
const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
|
|
76
|
-
const CLOSE_CODE_INVALID_RELAY_REQUEST = 4000;
|
|
77
|
-
const CLOSE_CODE_REPLACED_BY_NEW_MAC = 4001;
|
|
78
|
-
const CLOSE_CODE_MAC_UNAUTHORIZED = 4005;
|
|
79
93
|
const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
|
|
80
94
|
const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
81
95
|
const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
|
|
82
|
-
|
|
96
|
+
// Recent-turn window used only when a thread/read payload already exceeds the
|
|
97
|
+
// relay soft budget: heavy threads first paint with this many newest turns and
|
|
98
|
+
// older history arrives via thread/turns/list pagination. Normal threads are
|
|
99
|
+
// never trimmed.
|
|
100
|
+
const RELAY_HISTORY_RECENT_TURN_TARGET = 16;
|
|
83
101
|
const RELAY_TURNS_LIST_TARGET_BUDGET_MS = 5_500;
|
|
84
102
|
const RELAY_TURNS_LIST_BUDGET_RESERVE_MS = 1_000;
|
|
85
103
|
const RELAY_TURNS_LIST_MAX_INITIAL_LIMIT = 5;
|
|
@@ -87,7 +105,22 @@ const RELAY_TURNS_LIST_SAFE_RETRY_LIMIT = 5;
|
|
|
87
105
|
const RELAY_JSONL_TURNS_LIST_CACHE_TTL_MS = 30_000;
|
|
88
106
|
const RELAY_JSONL_ARTIFACT_CACHE_TTL_MS = 2_000;
|
|
89
107
|
const RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES = 128;
|
|
90
|
-
|
|
108
|
+
// Session cwd is stable for a rollout file, but the same thread can later get a
|
|
109
|
+
// newer rollout with a different cwd; cache entries are validated against file identity.
|
|
110
|
+
const RELAY_JSONL_THREAD_CWD_CACHE_TTL_MS = 5 * 60_000;
|
|
111
|
+
const RELAY_JSONL_THREAD_EMPTY_CWD_CACHE_TTL_MS = 30_000;
|
|
112
|
+
const RELAY_JSONL_FAST_FIRST_PAGE_WAIT_MS = 1_500;
|
|
113
|
+
// The phone may be backgrounded between the provisional JSONL page and its
|
|
114
|
+
// canonical reconciliation. Keep the handoff long enough that a normal
|
|
115
|
+
// foreground/reconnect does not turn a coherent first page into a dead cursor.
|
|
116
|
+
const RELAY_JSONL_CANONICAL_HANDOFF_TTL_MS = 10 * 60_000;
|
|
117
|
+
const RELAY_JSONL_CANONICAL_HANDOFF_MAX_ENTRIES = 32;
|
|
118
|
+
const JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX = "remodex-jsonl-handoff-v1:";
|
|
119
|
+
const RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES = Math.max(
|
|
120
|
+
0,
|
|
121
|
+
bufferConstants.MAX_STRING_LENGTH - (8 * 1024 * 1024)
|
|
122
|
+
);
|
|
123
|
+
const BRIDGE_PACKAGE_UPDATE_COMMAND = "npm install -g remodex@latest";
|
|
91
124
|
const BRIDGE_PACKAGE_UPDATE_TIMEOUT_MS = 180_000;
|
|
92
125
|
const BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS = 750;
|
|
93
126
|
const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
|
|
@@ -109,28 +142,17 @@ const RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS = [
|
|
|
109
142
|
"previousCursor",
|
|
110
143
|
"previous_cursor",
|
|
111
144
|
];
|
|
145
|
+
const RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS = new Set([
|
|
146
|
+
"prevCursor",
|
|
147
|
+
"prev_cursor",
|
|
148
|
+
"previousCursor",
|
|
149
|
+
"previous_cursor",
|
|
150
|
+
]);
|
|
112
151
|
const jsonlArtifactItemsCacheByThread = new Map();
|
|
152
|
+
const jsonlThreadCwdCacheByThread = new Map();
|
|
113
153
|
const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
|
|
114
154
|
const JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE = 200;
|
|
115
155
|
|
|
116
|
-
function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
|
|
117
|
-
const normalizedVersion = typeof version === "string" && version.trim()
|
|
118
|
-
? version.trim().replace(/\s+/g, "-")
|
|
119
|
-
: "dev";
|
|
120
|
-
return `RemodexBridge/${normalizedVersion}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
|
|
124
|
-
const token = normalizeNonEmptyString(
|
|
125
|
-
config.relayAccessToken
|
|
126
|
-
|| env.REMODEX_RELAY_ACCESS_TOKEN
|
|
127
|
-
|| env.PHODEX_RELAY_ACCESS_TOKEN
|
|
128
|
-
);
|
|
129
|
-
return token
|
|
130
|
-
? { "x-remodex-relay-token": token }
|
|
131
|
-
: {};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
156
|
function evictOldestEntries(map, maxSize) {
|
|
135
157
|
if (map.size <= maxSize) {
|
|
136
158
|
return;
|
|
@@ -143,6 +165,540 @@ function evictOldestEntries(map, maxSize) {
|
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
167
|
|
|
168
|
+
function createThreadTurnsListFastPageCoordinator({
|
|
169
|
+
waitMs = RELAY_JSONL_FAST_FIRST_PAGE_WAIT_MS,
|
|
170
|
+
handoffTTLms = RELAY_JSONL_CANONICAL_HANDOFF_TTL_MS,
|
|
171
|
+
maxHandoffs = RELAY_JSONL_CANONICAL_HANDOFF_MAX_ENTRIES,
|
|
172
|
+
payloadSoftLimitBytes = RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES,
|
|
173
|
+
sanitizeForRelay = sanitizeThreadHistoryImagesForRelay,
|
|
174
|
+
now = Date.now,
|
|
175
|
+
setTimeoutImpl = setTimeout,
|
|
176
|
+
clearTimeoutImpl = clearTimeout,
|
|
177
|
+
createToken = () => randomBytes(12).toString("hex"),
|
|
178
|
+
} = {}) {
|
|
179
|
+
const handoffsByToken = new Map();
|
|
180
|
+
const latestHandoffTokenByThread = new Map();
|
|
181
|
+
const canonicalFirstPageByKey = new Map();
|
|
182
|
+
|
|
183
|
+
function pruneHandoffs() {
|
|
184
|
+
const cutoff = now() - handoffTTLms;
|
|
185
|
+
for (const [token, entry] of handoffsByToken) {
|
|
186
|
+
if (entry.createdAt >= cutoff) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
handoffsByToken.delete(token);
|
|
190
|
+
if (latestHandoffTokenByThread.get(entry.threadId) === token) {
|
|
191
|
+
latestHandoffTokenByThread.delete(entry.threadId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const [cacheKey, entry] of canonicalFirstPageByKey) {
|
|
195
|
+
if (entry.createdAt < cutoff) {
|
|
196
|
+
canonicalFirstPageByKey.delete(cacheKey);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
while (handoffsByToken.size > maxHandoffs) {
|
|
200
|
+
const oldestToken = handoffsByToken.keys().next().value;
|
|
201
|
+
const oldest = handoffsByToken.get(oldestToken);
|
|
202
|
+
handoffsByToken.delete(oldestToken);
|
|
203
|
+
if (oldest && latestHandoffTokenByThread.get(oldest.threadId) === oldestToken) {
|
|
204
|
+
latestHandoffTokenByThread.delete(oldest.threadId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback) {
|
|
210
|
+
pruneHandoffs();
|
|
211
|
+
const token = createToken();
|
|
212
|
+
const entry = {
|
|
213
|
+
token,
|
|
214
|
+
threadId,
|
|
215
|
+
canonicalOutcomePromise,
|
|
216
|
+
hadNonEmptyJsonl: Boolean(jsonlFallback?.response),
|
|
217
|
+
anchorTurnId: firstTurnsListTurnId(jsonlFallback?.response),
|
|
218
|
+
createdAt: now(),
|
|
219
|
+
};
|
|
220
|
+
handoffsByToken.set(token, entry);
|
|
221
|
+
latestHandoffTokenByThread.set(threadId, token);
|
|
222
|
+
pruneHandoffs();
|
|
223
|
+
return token;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function consumeHandoff(entry) {
|
|
227
|
+
if (!entry?.token) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
handoffsByToken.delete(entry.token);
|
|
231
|
+
if (latestHandoffTokenByThread.get(entry.threadId) === entry.token) {
|
|
232
|
+
latestHandoffTokenByThread.delete(entry.threadId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalFirstPageOutcome(cacheKey, canonicalRequest, fetchCanonical) {
|
|
237
|
+
pruneHandoffs();
|
|
238
|
+
const existing = canonicalFirstPageByKey.get(cacheKey);
|
|
239
|
+
if (existing) {
|
|
240
|
+
return existing.canonicalOutcomePromise;
|
|
241
|
+
}
|
|
242
|
+
const canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
|
|
243
|
+
fetchCanonical(canonicalRequest)
|
|
244
|
+
);
|
|
245
|
+
canonicalFirstPageByKey.set(cacheKey, {
|
|
246
|
+
canonicalOutcomePromise,
|
|
247
|
+
createdAt: now(),
|
|
248
|
+
});
|
|
249
|
+
canonicalOutcomePromise.then(() => {
|
|
250
|
+
forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise);
|
|
251
|
+
});
|
|
252
|
+
return canonicalOutcomePromise;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function forgetCanonicalFirstPage(cacheKey, canonicalOutcomePromise) {
|
|
256
|
+
const existing = canonicalFirstPageByKey.get(cacheKey);
|
|
257
|
+
if (existing?.canonicalOutcomePromise === canonicalOutcomePromise) {
|
|
258
|
+
canonicalFirstPageByKey.delete(cacheKey);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readHandoffEntry(request) {
|
|
263
|
+
pruneHandoffs();
|
|
264
|
+
const threadId = threadIdFromRequestParams(request?.params);
|
|
265
|
+
const cursor = request?.params?.cursor;
|
|
266
|
+
const token = threadTurnsListHandoffDescriptor(cursor)?.token
|
|
267
|
+
|| latestHandoffTokenByThread.get(threadId)
|
|
268
|
+
|| "";
|
|
269
|
+
const entry = token ? handoffsByToken.get(token) : null;
|
|
270
|
+
return entry?.threadId === threadId ? entry : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function awaitCanonicalOutcome(canonicalOutcomePromise) {
|
|
274
|
+
const outcome = await canonicalOutcomePromise;
|
|
275
|
+
if (!outcome.ok) {
|
|
276
|
+
throw outcome.error;
|
|
277
|
+
}
|
|
278
|
+
return outcome.response;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function extendCanonicalResponseThroughAnchor(
|
|
282
|
+
response,
|
|
283
|
+
canonicalRequest,
|
|
284
|
+
anchorTurnId,
|
|
285
|
+
fetchCanonical,
|
|
286
|
+
maxPages = 12
|
|
287
|
+
) {
|
|
288
|
+
if (threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
|
|
289
|
+
return response;
|
|
290
|
+
}
|
|
291
|
+
const firstResult = response?.result;
|
|
292
|
+
const turnsKey = findTurnsListResultKey(firstResult);
|
|
293
|
+
if (!turnsKey) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let lastResult = firstResult;
|
|
298
|
+
let combinedTurns = [...firstResult[turnsKey]];
|
|
299
|
+
let cursor = readTurnsListNextCursor(firstResult);
|
|
300
|
+
const seenCursors = new Set();
|
|
301
|
+
for (let pageIndex = 0; pageIndex < maxPages && hasRelayCursor(cursor); pageIndex += 1) {
|
|
302
|
+
const cursorKey = JSON.stringify(cursor);
|
|
303
|
+
if (seenCursors.has(cursorKey)) {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
seenCursors.add(cursorKey);
|
|
307
|
+
const nextRequest = {
|
|
308
|
+
...canonicalRequest,
|
|
309
|
+
params: buildAdaptiveTurnsListPageParams(
|
|
310
|
+
canonicalRequest.params,
|
|
311
|
+
RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
|
|
312
|
+
cursor
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
const nextResponse = await awaitCanonicalOutcome(
|
|
316
|
+
settleThreadTurnsListCanonicalOutcome(fetchCanonical(nextRequest))
|
|
317
|
+
);
|
|
318
|
+
const nextResult = nextResponse?.result;
|
|
319
|
+
const nextTurnsKey = findTurnsListResultKey(nextResult);
|
|
320
|
+
if (!nextTurnsKey) {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
for (const turn of nextResult[nextTurnsKey]) {
|
|
324
|
+
const turnId = turnListTurnIdentifier(turn);
|
|
325
|
+
if (!turnId || !combinedTurns.some((existing) => turnListTurnIdentifier(existing) === turnId)) {
|
|
326
|
+
combinedTurns.push(turn);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lastResult = nextResult;
|
|
330
|
+
const combinedResponse = buildSafeTurnsListResponse(
|
|
331
|
+
canonicalRequest.id,
|
|
332
|
+
firstResult,
|
|
333
|
+
lastResult,
|
|
334
|
+
turnsKey,
|
|
335
|
+
combinedTurns
|
|
336
|
+
);
|
|
337
|
+
if (threadTurnsListResponseContainsAnchor(combinedResponse, anchorTurnId)) {
|
|
338
|
+
// The cursor belongs after every turn through the anchor. Keep that
|
|
339
|
+
// complete boundary intact, compacting items if needed; never slice
|
|
340
|
+
// turns and accidentally make the omitted range unreachable.
|
|
341
|
+
return buildCompactedCompleteTurnsListResponse({
|
|
342
|
+
requestId: canonicalRequest.id,
|
|
343
|
+
firstResult,
|
|
344
|
+
lastResult,
|
|
345
|
+
turnsKey,
|
|
346
|
+
turns: combinedTurns,
|
|
347
|
+
sanitizeForRelay,
|
|
348
|
+
sanitizeContext: buildThreadTurnsListRelaySanitizeContext(canonicalRequest),
|
|
349
|
+
payloadSoftLimitBytes,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const nextCursor = readTurnsListNextCursor(nextResult);
|
|
353
|
+
if (nextResult[nextTurnsKey].length === 0 || !hasRelayCursor(nextCursor)) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
cursor = nextCursor;
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function resolveCanonicalRequest(request, fetchCanonical, existingEntry = null, {
|
|
362
|
+
alignToHandoffAnchor = false,
|
|
363
|
+
validateHandoffAnchor = false,
|
|
364
|
+
handoffAnchorTurnId = "",
|
|
365
|
+
} = {}) {
|
|
366
|
+
const canonicalRequest = canonicalThreadTurnsListRequest(request);
|
|
367
|
+
let entry = existingEntry;
|
|
368
|
+
let response = null;
|
|
369
|
+
const anchorTurnId = entry?.anchorTurnId || handoffAnchorTurnId;
|
|
370
|
+
const canMatchCanonicalAnchor = anchorTurnId
|
|
371
|
+
&& !isSyntheticJsonlHistoryTurnId(anchorTurnId);
|
|
372
|
+
if (entry) {
|
|
373
|
+
const observedOutcomePromise = entry.canonicalOutcomePromise;
|
|
374
|
+
const firstOutcome = await observedOutcomePromise;
|
|
375
|
+
const firstResponseIsUsable = firstOutcome.ok
|
|
376
|
+
&& !isEmptyTurnsListResponse(firstOutcome.response);
|
|
377
|
+
if (firstResponseIsUsable) {
|
|
378
|
+
response = firstOutcome.response;
|
|
379
|
+
} else {
|
|
380
|
+
if (entry.canonicalOutcomePromise === observedOutcomePromise) {
|
|
381
|
+
entry.canonicalOutcomePromise = settleThreadTurnsListCanonicalOutcome(
|
|
382
|
+
fetchCanonical(canonicalRequest)
|
|
383
|
+
);
|
|
384
|
+
entry.createdAt = now();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
response = response || await awaitCanonicalOutcome(
|
|
390
|
+
entry?.canonicalOutcomePromise
|
|
391
|
+
|| settleThreadTurnsListCanonicalOutcome(fetchCanonical(canonicalRequest))
|
|
392
|
+
);
|
|
393
|
+
if (entry?.hadNonEmptyJsonl && isEmptyTurnsListResponse(response)) {
|
|
394
|
+
throw new Error("Canonical thread history was empty after a non-empty JSONL first page.");
|
|
395
|
+
}
|
|
396
|
+
if (validateHandoffAnchor
|
|
397
|
+
&& canMatchCanonicalAnchor
|
|
398
|
+
&& !threadTurnsListResponseContainsAnchor(response, anchorTurnId)) {
|
|
399
|
+
response = await extendCanonicalResponseThroughAnchor(
|
|
400
|
+
response,
|
|
401
|
+
canonicalRequest,
|
|
402
|
+
anchorTurnId,
|
|
403
|
+
fetchCanonical
|
|
404
|
+
);
|
|
405
|
+
if (!response) {
|
|
406
|
+
throw new Error("Canonical history does not contain the JSONL handoff anchor yet.");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const rebound = rebindThreadTurnsListResponseId(response, request.id);
|
|
410
|
+
if (!alignToHandoffAnchor || !canMatchCanonicalAnchor) {
|
|
411
|
+
return rebound;
|
|
412
|
+
}
|
|
413
|
+
const aligned = alignThreadTurnsListResponseToAnchor(rebound, anchorTurnId);
|
|
414
|
+
if (!aligned) {
|
|
415
|
+
throw new Error("Canonical history no longer contains the JSONL handoff anchor.");
|
|
416
|
+
}
|
|
417
|
+
return aligned;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function resolve(request, { fetchCanonical, readJsonl }) {
|
|
421
|
+
const params = request?.params || {};
|
|
422
|
+
const cursor = params.cursor;
|
|
423
|
+
const handoffDescriptor = threadTurnsListHandoffDescriptor(cursor);
|
|
424
|
+
const isHandoffRequest = cursor === JSONL_OLDER_HANDOFF_CURSOR
|
|
425
|
+
|| Boolean(handoffDescriptor);
|
|
426
|
+
const requiresCanonical = params.remodexRequireCanonical === true;
|
|
427
|
+
const hasOrdinaryCursor = hasRelayCursor(cursor) && !isHandoffRequest;
|
|
428
|
+
|
|
429
|
+
if (hasOrdinaryCursor) {
|
|
430
|
+
return {
|
|
431
|
+
source: "canonical",
|
|
432
|
+
response: await resolveCanonicalRequest(request, fetchCanonical),
|
|
433
|
+
usesJsonl: false,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (isHandoffRequest || requiresCanonical) {
|
|
438
|
+
const handoffEntry = readHandoffEntry(request);
|
|
439
|
+
const response = await resolveCanonicalRequest(request, fetchCanonical, handoffEntry, {
|
|
440
|
+
alignToHandoffAnchor: isHandoffRequest,
|
|
441
|
+
validateHandoffAnchor: true,
|
|
442
|
+
handoffAnchorTurnId: handoffDescriptor?.anchorTurnId || "",
|
|
443
|
+
});
|
|
444
|
+
consumeHandoff(handoffEntry);
|
|
445
|
+
return {
|
|
446
|
+
source: "canonical",
|
|
447
|
+
response,
|
|
448
|
+
usesJsonl: false,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const canonicalRequest = canonicalThreadTurnsListRequest(request);
|
|
453
|
+
const threadId = threadIdFromRequestParams(params);
|
|
454
|
+
const canonicalFirstPageCacheKey = canonicalThreadTurnsListRequestShapeKey(canonicalRequest);
|
|
455
|
+
const canonicalOutcomePromise = canonicalFirstPageOutcome(
|
|
456
|
+
canonicalFirstPageCacheKey,
|
|
457
|
+
canonicalRequest,
|
|
458
|
+
fetchCanonical
|
|
459
|
+
);
|
|
460
|
+
let jsonlFallback = null;
|
|
461
|
+
try {
|
|
462
|
+
jsonlFallback = await readJsonl(request);
|
|
463
|
+
} catch {
|
|
464
|
+
jsonlFallback = null;
|
|
465
|
+
}
|
|
466
|
+
// A rollout tail is a useful emergency baseline only when it contains a
|
|
467
|
+
// whole turn package. Never let a bare tail (for example a file-change or
|
|
468
|
+
// final assistant fragment) win the race with canonical history: iOS would
|
|
469
|
+
// render it as a complete conversation and then merge the real opener in
|
|
470
|
+
// later, which is exactly how orphan cards and duplicate rows appeared.
|
|
471
|
+
if (jsonlFallback?.response && !isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
|
|
472
|
+
jsonlFallback = null;
|
|
473
|
+
}
|
|
474
|
+
if (!jsonlFallback?.response) {
|
|
475
|
+
const response = await awaitCanonicalOutcome(canonicalOutcomePromise);
|
|
476
|
+
forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
|
|
477
|
+
return {
|
|
478
|
+
source: "canonical",
|
|
479
|
+
response: rebindThreadTurnsListResponseId(response, request.id),
|
|
480
|
+
usesJsonl: false,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let timeoutId = null;
|
|
485
|
+
const deadline = new Promise((resolveDeadline) => {
|
|
486
|
+
timeoutId = setTimeoutImpl(() => resolveDeadline({ deadline: true }), waitMs);
|
|
487
|
+
});
|
|
488
|
+
const first = await Promise.race([canonicalOutcomePromise, deadline]);
|
|
489
|
+
if (timeoutId != null) {
|
|
490
|
+
clearTimeoutImpl(timeoutId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (first?.ok && !isEmptyTurnsListResponse(first.response)) {
|
|
494
|
+
if (shouldPreferJsonlFirstPage(first.response, jsonlFallback.response)) {
|
|
495
|
+
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
496
|
+
return {
|
|
497
|
+
source: "jsonl",
|
|
498
|
+
response: buildJsonlCanonicalHandoffResponse(
|
|
499
|
+
jsonlFallback.response,
|
|
500
|
+
request.id,
|
|
501
|
+
token,
|
|
502
|
+
firstTurnsListTurnId(jsonlFallback.response)
|
|
503
|
+
),
|
|
504
|
+
usesJsonl: true,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
forgetCanonicalFirstPage(canonicalFirstPageCacheKey, canonicalOutcomePromise);
|
|
508
|
+
return {
|
|
509
|
+
source: "canonical",
|
|
510
|
+
response: rebindThreadTurnsListResponseId(first.response, request.id),
|
|
511
|
+
usesJsonl: false,
|
|
512
|
+
jsonlFallback,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const token = rememberHandoff(threadId, canonicalOutcomePromise, jsonlFallback);
|
|
517
|
+
return {
|
|
518
|
+
source: "jsonl",
|
|
519
|
+
response: buildJsonlCanonicalHandoffResponse(
|
|
520
|
+
jsonlFallback.response,
|
|
521
|
+
request.id,
|
|
522
|
+
token,
|
|
523
|
+
firstTurnsListTurnId(jsonlFallback.response)
|
|
524
|
+
),
|
|
525
|
+
usesJsonl: true,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { resolve };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function settleThreadTurnsListCanonicalOutcome(promise) {
|
|
533
|
+
return Promise.resolve(promise).then(
|
|
534
|
+
(response) => ({ ok: true, response }),
|
|
535
|
+
(error) => ({ ok: false, error })
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function threadTurnsListHandoffDescriptor(cursor) {
|
|
540
|
+
if (typeof cursor !== "string" || !cursor.startsWith(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX)) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const raw = cursor.slice(JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX.length);
|
|
544
|
+
const separatorIndex = raw.lastIndexOf(":");
|
|
545
|
+
if (separatorIndex < 0) {
|
|
546
|
+
return raw ? { anchorTurnId: "", token: raw } : null;
|
|
547
|
+
}
|
|
548
|
+
const token = raw.slice(separatorIndex + 1);
|
|
549
|
+
if (!token) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
let anchorTurnId = "";
|
|
553
|
+
try {
|
|
554
|
+
anchorTurnId = decodeURIComponent(raw.slice(0, separatorIndex));
|
|
555
|
+
} catch {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
return { anchorTurnId, token };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function canonicalThreadTurnsListRequest(request) {
|
|
562
|
+
const params = { ...(request?.params || {}) };
|
|
563
|
+
delete params.remodexRequireCanonical;
|
|
564
|
+
delete params.remodexTurnStateOnly;
|
|
565
|
+
if (params.cursor === JSONL_OLDER_HANDOFF_CURSOR || threadTurnsListHandoffDescriptor(params.cursor)) {
|
|
566
|
+
delete params.cursor;
|
|
567
|
+
}
|
|
568
|
+
return { ...request, params };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function canonicalThreadTurnsListRequestShapeKey(canonicalRequest) {
|
|
572
|
+
const params = canonicalRequest?.params || {};
|
|
573
|
+
return JSON.stringify(sortJsonValueForCacheKey({
|
|
574
|
+
threadId: threadIdFromRequestParams(params),
|
|
575
|
+
params,
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function sortJsonValueForCacheKey(value) {
|
|
580
|
+
if (Array.isArray(value)) {
|
|
581
|
+
return value.map(sortJsonValueForCacheKey);
|
|
582
|
+
}
|
|
583
|
+
if (!value || typeof value !== "object") {
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
return Object.fromEntries(
|
|
587
|
+
Object.keys(value)
|
|
588
|
+
.sort()
|
|
589
|
+
.map((key) => [key, sortJsonValueForCacheKey(value[key])])
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function rebindThreadTurnsListResponseId(response, requestId) {
|
|
594
|
+
return response && typeof response === "object"
|
|
595
|
+
? { ...response, id: requestId }
|
|
596
|
+
: response;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildJsonlCanonicalHandoffResponse(response, requestId, token, anchorTurnId = "") {
|
|
600
|
+
const result = response?.result;
|
|
601
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
602
|
+
return response;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
...response,
|
|
606
|
+
id: requestId,
|
|
607
|
+
result: {
|
|
608
|
+
...result,
|
|
609
|
+
nextCursor: `${JSONL_CANONICAL_HANDOFF_CURSOR_PREFIX}${encodeURIComponent(anchorTurnId)}:${token}`,
|
|
610
|
+
remodexJsonlFallback: true,
|
|
611
|
+
remodexCanonicalHandoff: true,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function shouldPreferJsonlFirstPage(canonicalResponse, jsonlResponse) {
|
|
617
|
+
const canonicalResult = canonicalResponse?.result;
|
|
618
|
+
const jsonlResult = jsonlResponse?.result;
|
|
619
|
+
const canonicalTurnsKey = findTurnsListResultKey(canonicalResult);
|
|
620
|
+
const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
|
|
621
|
+
if (!canonicalTurnsKey || !jsonlTurnsKey) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
const jsonlTurn = jsonlResult[jsonlTurnsKey]?.[0];
|
|
625
|
+
const jsonlTurnId = turnListTurnIdentifier(jsonlTurn);
|
|
626
|
+
return Boolean(jsonlTurnId)
|
|
627
|
+
&& !canonicalResult[canonicalTurnsKey].some((turn) => turnListTurnIdentifier(turn) === jsonlTurnId)
|
|
628
|
+
&& shouldMergeLatestJsonlTurn(jsonlTurn);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function firstTurnsListTurnId(response) {
|
|
632
|
+
const result = response?.result;
|
|
633
|
+
const turnsKey = findTurnsListResultKey(result);
|
|
634
|
+
return turnsKey ? turnListTurnIdentifier(result[turnsKey]?.[0]) : "";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function isCoherentJsonlFirstPageResponse(response) {
|
|
638
|
+
const result = response?.result;
|
|
639
|
+
const turnsKey = findTurnsListResultKey(result);
|
|
640
|
+
const turns = turnsKey ? result[turnsKey] : null;
|
|
641
|
+
if (!Array.isArray(turns) || turns.length === 0) {
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
// A running turn must contain its materialized user opener. Otherwise an
|
|
645
|
+
// orphan file card or assistant tail can win the fast-page race and later
|
|
646
|
+
// be mistaken for a complete conversation. Explicit terminal turns are
|
|
647
|
+
// allowed without a user item because older compacted/system turns can be
|
|
648
|
+
// legitimately item-only.
|
|
649
|
+
const newestTurn = turns[0];
|
|
650
|
+
const items = Array.isArray(newestTurn?.items) ? newestTurn.items : null;
|
|
651
|
+
if (!items) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
if (items.length === 0) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
const status = String(newestTurn?.status || "").replace(/[_-]/g, "").toLowerCase();
|
|
658
|
+
const isExplicitTerminal = new Set(["completed", "failed", "aborted", "cancelled", "canceled", "interrupted"])
|
|
659
|
+
.has(status);
|
|
660
|
+
if (isExplicitTerminal) {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
return items.some((item) => {
|
|
664
|
+
const role = String(item?.role || "").toLowerCase();
|
|
665
|
+
const type = String(item?.type || "").replace(/[_-]/g, "").toLowerCase();
|
|
666
|
+
return role === "user" || type === "usermessage";
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function threadTurnsListResponseContainsAnchor(response, anchorTurnId) {
|
|
671
|
+
const result = response?.result;
|
|
672
|
+
const turnsKey = findTurnsListResultKey(result);
|
|
673
|
+
return Boolean(turnsKey) && result[turnsKey].some((turn) => (
|
|
674
|
+
turnListTurnIdentifier(turn) === anchorTurnId
|
|
675
|
+
));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function alignThreadTurnsListResponseToAnchor(response, anchorTurnId) {
|
|
679
|
+
const result = response?.result;
|
|
680
|
+
const turnsKey = findTurnsListResultKey(result);
|
|
681
|
+
if (!turnsKey) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const anchorIndex = result[turnsKey].findIndex((turn) => (
|
|
685
|
+
turnListTurnIdentifier(turn) === anchorTurnId
|
|
686
|
+
));
|
|
687
|
+
if (anchorIndex < 0) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
if (anchorIndex === 0) {
|
|
691
|
+
return response;
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
...response,
|
|
695
|
+
result: {
|
|
696
|
+
...result,
|
|
697
|
+
[turnsKey]: result[turnsKey].slice(anchorIndex),
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
146
702
|
function startBridge({
|
|
147
703
|
config: explicitConfig = null,
|
|
148
704
|
printPairingQr = true,
|
|
@@ -181,6 +737,9 @@ function startBridge({
|
|
|
181
737
|
const notificationSecret = randomBytes(24).toString("hex");
|
|
182
738
|
const desktopRefresher = new CodexDesktopRefresher({
|
|
183
739
|
enabled: config.refreshEnabled,
|
|
740
|
+
// With IPC live sync streaming content, deep-link refreshes are only needed
|
|
741
|
+
// to navigate Desktop onto the phone-driven thread, not to reload content.
|
|
742
|
+
navigationOnly: config.desktopIpcLiveSyncEnabled,
|
|
184
743
|
debounceMs: config.refreshDebounceMs,
|
|
185
744
|
refreshCommand: config.refreshCommand,
|
|
186
745
|
bundleId: config.codexBundleId,
|
|
@@ -209,18 +768,17 @@ function startBridge({
|
|
|
209
768
|
let relayWatchdogTimer = null;
|
|
210
769
|
let lastRelayActivityAt = 0;
|
|
211
770
|
let lastConnectionStatus = null;
|
|
212
|
-
let lastConnectionError = "";
|
|
213
771
|
let codexLaunchState = config.codexEndpoint ? "connected" : "starting";
|
|
214
772
|
let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
|
|
215
773
|
const forwardedInitializeRequestIds = new Set();
|
|
216
774
|
const bridgeManagedCodexRequestWaiters = new Map();
|
|
217
775
|
const forwardedRequestMethodsById = new Map();
|
|
218
776
|
const relaySanitizedResponseMethodsById = new Map();
|
|
219
|
-
const
|
|
220
|
-
const codexResponseRoutesById = new Map();
|
|
221
|
-
const extraRelaySessionCount = readExtraRelaySessionCount(process.env);
|
|
777
|
+
const desktopIpcLiveOwnerObservedInboundKeys = new Set();
|
|
222
778
|
const jsonlTurnsListRolloutCacheByThread = new Map();
|
|
223
779
|
const jsonlTurnsListRolloutMissCacheByThread = new Map();
|
|
780
|
+
const threadTurnsListFastPageCoordinator = createThreadTurnsListFastPageCoordinator();
|
|
781
|
+
const threadRuntimeSettingsStore = createThreadRuntimeSettingsStore();
|
|
224
782
|
const trackedForwardedRequestMethods = new Set([
|
|
225
783
|
"account/login/start",
|
|
226
784
|
"account/login/cancel",
|
|
@@ -257,7 +815,6 @@ function startBridge({
|
|
|
257
815
|
}
|
|
258
816
|
},
|
|
259
817
|
});
|
|
260
|
-
let primaryRelayChannel = null;
|
|
261
818
|
// Keeps one stable sender identity across reconnects so buffered replay state
|
|
262
819
|
// reflects what actually made it onto the current relay socket.
|
|
263
820
|
function sendRelayWireMessage(wireMessage) {
|
|
@@ -273,6 +830,14 @@ function startBridge({
|
|
|
273
830
|
const rolloutLiveMirror = !config.codexEndpoint
|
|
274
831
|
? createRolloutLiveMirrorController({
|
|
275
832
|
sendApplicationResponse,
|
|
833
|
+
// One live source per thread. The follower keeps fresh/idle Desktop state
|
|
834
|
+
// authoritative, but yields an active cache that stopped broadcasting;
|
|
835
|
+
// a later Desktop snapshot is announced as a new source epoch so the
|
|
836
|
+
// phone performs canonical repair instead of mixing both mirrors.
|
|
837
|
+
shouldSuppressThread: (threadId) => shouldSuppressRolloutMirrorForThread(
|
|
838
|
+
threadId,
|
|
839
|
+
{ desktopIpcActionFollower, desktopIpcLiveOwner }
|
|
840
|
+
),
|
|
276
841
|
})
|
|
277
842
|
: null;
|
|
278
843
|
const desktopIpcActionFollower = !config.codexEndpoint
|
|
@@ -281,7 +846,29 @@ function startBridge({
|
|
|
281
846
|
readConversationState: async (threadId) => seedConversationStateFromThreadRead(
|
|
282
847
|
await sendCodexRequest("thread/read", { threadId })
|
|
283
848
|
),
|
|
849
|
+
forwardToLocalCodex: (rawMessage) => {
|
|
850
|
+
observeDesktopIpcLiveOwnerInbound(rawMessage);
|
|
851
|
+
forwardInboundRequestToCodex(rawMessage);
|
|
852
|
+
},
|
|
853
|
+
// Threads streamed by the bridge's own app-server must never be held,
|
|
854
|
+
// served from Desktop echoes, or routed over the IPC bus.
|
|
855
|
+
isLocallyOwnedThread: (threadId) => Boolean(desktopIpcLiveOwner?.isThreadOwned(threadId)),
|
|
856
|
+
normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
|
|
857
|
+
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
284
858
|
socketPath: config.desktopIpcSocketPath || undefined,
|
|
859
|
+
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
860
|
+
})
|
|
861
|
+
: null;
|
|
862
|
+
const desktopIpcLiveOwner = !config.codexEndpoint
|
|
863
|
+
? createDesktopIpcLiveOwner({
|
|
864
|
+
enabled: config.desktopIpcLiveSyncEnabled !== false,
|
|
865
|
+
sendApplicationResponse,
|
|
866
|
+
sendCodexRequest,
|
|
867
|
+
sendRawCodexMessage: (rawMessage) => codex.send(rawMessage),
|
|
868
|
+
normalizeTurnStartParams: normalizeTurnStartParamsForCodex,
|
|
869
|
+
runtimeSettingsStore: threadRuntimeSettingsStore,
|
|
870
|
+
socketPath: config.desktopIpcSocketPath || undefined,
|
|
871
|
+
snapshotDebounceMs: config.desktopIpcSnapshotDebounceMs,
|
|
285
872
|
})
|
|
286
873
|
: null;
|
|
287
874
|
let contextUsageWatcher = null;
|
|
@@ -293,6 +880,14 @@ function startBridge({
|
|
|
293
880
|
appPath: config.codexAppPath,
|
|
294
881
|
logPrefix: "[remodex]",
|
|
295
882
|
});
|
|
883
|
+
const projectRegistry = createProjectRegistry();
|
|
884
|
+
const runtimeProviderRouter = createRuntimeProviderRouter({
|
|
885
|
+
sendApplicationResponse,
|
|
886
|
+
sendCodexRequest,
|
|
887
|
+
sendRuntimeMessage: sendRuntimeApplicationMessage,
|
|
888
|
+
projectRegistry,
|
|
889
|
+
logPrefix: "[remodex]",
|
|
890
|
+
});
|
|
296
891
|
const voiceHandler = createVoiceHandler({
|
|
297
892
|
sendCodexRequest,
|
|
298
893
|
logPrefix: "[remodex]",
|
|
@@ -370,9 +965,11 @@ function startBridge({
|
|
|
370
965
|
clearReconnectTimer();
|
|
371
966
|
clearRelayWatchdog();
|
|
372
967
|
bridgeStatusPublisher.stopHeartbeat();
|
|
968
|
+
runtimeProviderRouter.shutdown();
|
|
373
969
|
stopContextUsageWatcher();
|
|
374
970
|
rolloutLiveMirror?.stopAll();
|
|
375
971
|
desktopIpcActionFollower?.stopAll();
|
|
972
|
+
desktopIpcLiveOwner?.stopAll();
|
|
376
973
|
}
|
|
377
974
|
|
|
378
975
|
function stopBridge() {
|
|
@@ -422,13 +1019,12 @@ function startBridge({
|
|
|
422
1019
|
}
|
|
423
1020
|
|
|
424
1021
|
// Keeps npm start output compact by emitting only high-signal connection states.
|
|
425
|
-
function logConnectionStatus(status
|
|
426
|
-
if (lastConnectionStatus === status
|
|
1022
|
+
function logConnectionStatus(status) {
|
|
1023
|
+
if (lastConnectionStatus === status) {
|
|
427
1024
|
return;
|
|
428
1025
|
}
|
|
429
1026
|
|
|
430
1027
|
lastConnectionStatus = status;
|
|
431
|
-
lastConnectionError = lastError;
|
|
432
1028
|
if (status !== "connected") {
|
|
433
1029
|
activePhoneSummary = null;
|
|
434
1030
|
}
|
|
@@ -436,16 +1032,13 @@ function startBridge({
|
|
|
436
1032
|
state: "running",
|
|
437
1033
|
connectionStatus: status,
|
|
438
1034
|
pid: process.pid,
|
|
439
|
-
lastError,
|
|
1035
|
+
lastError: "",
|
|
440
1036
|
});
|
|
441
1037
|
console.log(`[remodex] ${status}`);
|
|
442
|
-
if (lastError) {
|
|
443
|
-
console.error(`[remodex] ${lastError}`);
|
|
444
|
-
}
|
|
445
1038
|
}
|
|
446
1039
|
|
|
447
1040
|
// Retries the relay socket while preserving the active Codex process and session id.
|
|
448
|
-
function scheduleRelayReconnect(closeCode
|
|
1041
|
+
function scheduleRelayReconnect(closeCode) {
|
|
449
1042
|
if (isShuttingDown) {
|
|
450
1043
|
return;
|
|
451
1044
|
}
|
|
@@ -485,10 +1078,8 @@ function startBridge({
|
|
|
485
1078
|
},
|
|
486
1079
|
// The relay uses this per-session secret to authenticate the first push registration.
|
|
487
1080
|
headers: {
|
|
488
|
-
"User-Agent": buildRelayUserAgentHeader(),
|
|
489
1081
|
"x-role": "mac",
|
|
490
1082
|
"x-notification-secret": notificationSecret,
|
|
491
|
-
...buildRelayAccessTokenHeaders(config),
|
|
492
1083
|
...buildMacRegistrationHeaders(deviceState, pairingSession),
|
|
493
1084
|
},
|
|
494
1085
|
});
|
|
@@ -514,7 +1105,7 @@ function startBridge({
|
|
|
514
1105
|
}
|
|
515
1106
|
},
|
|
516
1107
|
onApplicationMessage(plaintextMessage) {
|
|
517
|
-
handleApplicationMessage(plaintextMessage
|
|
1108
|
+
handleApplicationMessage(plaintextMessage);
|
|
518
1109
|
},
|
|
519
1110
|
})) {
|
|
520
1111
|
return;
|
|
@@ -529,12 +1120,11 @@ function startBridge({
|
|
|
529
1120
|
markRelayActivity();
|
|
530
1121
|
});
|
|
531
1122
|
|
|
532
|
-
nextSocket.on("close", (code
|
|
533
|
-
const closeReason = normalizeWebSocketCloseReason(reason);
|
|
1123
|
+
nextSocket.on("close", (code) => {
|
|
534
1124
|
if (socket === nextSocket) {
|
|
535
1125
|
clearRelayWatchdog();
|
|
536
1126
|
}
|
|
537
|
-
logConnectionStatus("disconnected"
|
|
1127
|
+
logConnectionStatus("disconnected");
|
|
538
1128
|
if (socket === nextSocket) {
|
|
539
1129
|
socket = null;
|
|
540
1130
|
}
|
|
@@ -542,7 +1132,7 @@ function startBridge({
|
|
|
542
1132
|
// Relay reconnects are transport-only: keep local live observers running
|
|
543
1133
|
// so their output can enter secure replay and catch up on the next resume.
|
|
544
1134
|
desktopRefresher.handleTransportReset();
|
|
545
|
-
scheduleRelayReconnect(code
|
|
1135
|
+
scheduleRelayReconnect(code);
|
|
546
1136
|
});
|
|
547
1137
|
|
|
548
1138
|
nextSocket.on("error", () => {
|
|
@@ -553,191 +1143,45 @@ function startBridge({
|
|
|
553
1143
|
});
|
|
554
1144
|
}
|
|
555
1145
|
|
|
556
|
-
// Optional draft path: one bridge process can expose extra one-mobile relay sessions without changing relay behavior.
|
|
557
|
-
function startExtraRelayChannels() {
|
|
558
|
-
for (let index = 1; index <= extraRelaySessionCount; index += 1) {
|
|
559
|
-
startExtraRelayChannel(index);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function startExtraRelayChannel(index) {
|
|
564
|
-
const extraSessionId = randomUUID();
|
|
565
|
-
const extraRelaySessionUrl = `${relayBaseUrl}/${extraSessionId}`;
|
|
566
|
-
let reconnectTimerForChannel = null;
|
|
567
|
-
let reconnectAttemptForChannel = 0;
|
|
568
|
-
const extraSecureTransport = createBridgeSecureTransport({
|
|
569
|
-
sessionId: extraSessionId,
|
|
570
|
-
relayUrl: relayBaseUrl,
|
|
571
|
-
deviceState,
|
|
572
|
-
onTrustedPhoneUpdate(nextDeviceState) {
|
|
573
|
-
deviceState = nextDeviceState;
|
|
574
|
-
sendRelayRegistrationUpdate(nextDeviceState);
|
|
575
|
-
sendExtraRelayRegistrationUpdate(extraRelayChannel, nextDeviceState);
|
|
576
|
-
},
|
|
577
|
-
});
|
|
578
|
-
const extraPairingSession = {
|
|
579
|
-
pairingPayload: extraSecureTransport.createPairingPayload(),
|
|
580
|
-
pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
|
|
581
|
-
};
|
|
582
|
-
const extraRelayChannel = {
|
|
583
|
-
label: `extra-${index}`,
|
|
584
|
-
pairingSession: extraPairingSession,
|
|
585
|
-
secureTransport: extraSecureTransport,
|
|
586
|
-
socket: null,
|
|
587
|
-
closing: false,
|
|
588
|
-
sendWireMessage(wireMessage) {
|
|
589
|
-
if (extraRelayChannel.socket?.readyState !== WebSocket.OPEN) {
|
|
590
|
-
return false;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
extraRelayChannel.socket.send(wireMessage);
|
|
594
|
-
return true;
|
|
595
|
-
},
|
|
596
|
-
close() {
|
|
597
|
-
extraRelayChannel.closing = true;
|
|
598
|
-
if (reconnectTimerForChannel) {
|
|
599
|
-
clearTimeout(reconnectTimerForChannel);
|
|
600
|
-
reconnectTimerForChannel = null;
|
|
601
|
-
}
|
|
602
|
-
if (
|
|
603
|
-
extraRelayChannel.socket?.readyState === WebSocket.OPEN
|
|
604
|
-
|| extraRelayChannel.socket?.readyState === WebSocket.CONNECTING
|
|
605
|
-
) {
|
|
606
|
-
extraRelayChannel.socket.close();
|
|
607
|
-
}
|
|
608
|
-
},
|
|
609
|
-
};
|
|
610
|
-
relayChannels.push(extraRelayChannel);
|
|
611
|
-
|
|
612
|
-
if (printPairingQr) {
|
|
613
|
-
console.error(`[remodex] Pair device ${index + 1}: scan this QR from your other device.`);
|
|
614
|
-
printQR(extraPairingSession);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
connectExtraRelay();
|
|
618
|
-
|
|
619
|
-
function scheduleExtraReconnect() {
|
|
620
|
-
if (isShuttingDown || extraRelayChannel.closing || reconnectTimerForChannel) {
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
reconnectAttemptForChannel += 1;
|
|
625
|
-
const delayMs = Math.min(1_000 * reconnectAttemptForChannel, 5_000);
|
|
626
|
-
reconnectTimerForChannel = setTimeout(() => {
|
|
627
|
-
reconnectTimerForChannel = null;
|
|
628
|
-
connectExtraRelay();
|
|
629
|
-
}, delayMs);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function connectExtraRelay() {
|
|
633
|
-
if (isShuttingDown || extraRelayChannel.closing) {
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const nextSocket = new WebSocket(extraRelaySessionUrl, {
|
|
638
|
-
headers: {
|
|
639
|
-
"User-Agent": buildRelayUserAgentHeader(),
|
|
640
|
-
"x-role": "mac",
|
|
641
|
-
"x-notification-secret": notificationSecret,
|
|
642
|
-
...buildRelayAccessTokenHeaders(config),
|
|
643
|
-
...buildMacRegistrationHeaders(deviceState, extraPairingSession),
|
|
644
|
-
},
|
|
645
|
-
});
|
|
646
|
-
extraRelayChannel.socket = nextSocket;
|
|
647
|
-
|
|
648
|
-
nextSocket.on("open", () => {
|
|
649
|
-
reconnectAttemptForChannel = 0;
|
|
650
|
-
extraSecureTransport.bindLiveSendWireMessage(extraRelayChannel.sendWireMessage);
|
|
651
|
-
sendExtraRelayRegistrationUpdate(extraRelayChannel, deviceState);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
nextSocket.on("message", (data) => {
|
|
655
|
-
const message = typeof data === "string" ? data : data.toString("utf8");
|
|
656
|
-
extraSecureTransport.handleIncomingWireMessage(message, {
|
|
657
|
-
sendControlMessage(controlMessage) {
|
|
658
|
-
if (nextSocket.readyState === WebSocket.OPEN) {
|
|
659
|
-
nextSocket.send(JSON.stringify(controlMessage));
|
|
660
|
-
}
|
|
661
|
-
},
|
|
662
|
-
onApplicationMessage(plaintextMessage) {
|
|
663
|
-
handleApplicationMessage(plaintextMessage, extraRelayChannel);
|
|
664
|
-
},
|
|
665
|
-
});
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
nextSocket.on("close", () => {
|
|
669
|
-
if (extraRelayChannel.socket === nextSocket) {
|
|
670
|
-
extraRelayChannel.socket = null;
|
|
671
|
-
}
|
|
672
|
-
scheduleExtraReconnect();
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
nextSocket.on("error", () => {});
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function closeExtraRelayChannels() {
|
|
680
|
-
for (const relayChannel of relayChannels) {
|
|
681
|
-
if (relayChannel !== primaryRelayChannel && typeof relayChannel.close === "function") {
|
|
682
|
-
relayChannel.close();
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
function sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState) {
|
|
688
|
-
if (!relayChannel?.socket || relayChannel.socket.readyState !== WebSocket.OPEN) {
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
relayChannel.socket.send(JSON.stringify({
|
|
693
|
-
kind: "relayMacRegistration",
|
|
694
|
-
registration: buildMacRegistration(nextDeviceState, relayChannel.pairingSession),
|
|
695
|
-
}));
|
|
696
|
-
}
|
|
697
|
-
|
|
698
1146
|
const pairingPayload = secureTransport.createPairingPayload();
|
|
699
1147
|
const pairingSession = {
|
|
700
1148
|
pairingPayload,
|
|
701
1149
|
pairingCode: createShortPairingCode({ length: SHORT_PAIRING_CODE_LENGTH }),
|
|
702
1150
|
};
|
|
703
|
-
primaryRelayChannel = {
|
|
704
|
-
label: "primary",
|
|
705
|
-
pairingSession,
|
|
706
|
-
secureTransport,
|
|
707
|
-
sendWireMessage: sendRelayWireMessage,
|
|
708
|
-
};
|
|
709
|
-
relayChannels.push(primaryRelayChannel);
|
|
710
1151
|
onPairingSession?.(pairingSession);
|
|
711
1152
|
if (printPairingQr) {
|
|
712
|
-
if (extraRelaySessionCount > 0) {
|
|
713
|
-
console.error("[remodex] Pair device 1: scan this QR from your first device.");
|
|
714
|
-
}
|
|
715
1153
|
printQR(pairingSession);
|
|
716
1154
|
}
|
|
717
1155
|
pushServiceClient.logUnavailable();
|
|
718
1156
|
connectRelay();
|
|
719
|
-
startExtraRelayChannels();
|
|
720
1157
|
|
|
721
1158
|
codex.onMessage((message) => {
|
|
722
|
-
|
|
1159
|
+
// Streaming deltas make this the hottest path in the bridge: parse the
|
|
1160
|
+
// envelope once and share the read-only object with every observer.
|
|
1161
|
+
const parsedMessage = parseBridgeMessage(message);
|
|
1162
|
+
if (handleBridgeManagedCodexResponse(message, parsedMessage)) {
|
|
723
1163
|
return;
|
|
724
1164
|
}
|
|
725
|
-
updatePendingAuthLoginFromCodexMessage(message);
|
|
726
|
-
trackCodexHandshakeState(message);
|
|
727
|
-
desktopRefresher.handleOutbound(message);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1165
|
+
updatePendingAuthLoginFromCodexMessage(message, parsedMessage);
|
|
1166
|
+
trackCodexHandshakeState(message, parsedMessage);
|
|
1167
|
+
desktopRefresher.handleOutbound(message, parsedMessage);
|
|
1168
|
+
desktopIpcLiveOwner?.observeOutbound(message, parsedMessage);
|
|
1169
|
+
pushNotificationTracker.handleOutbound(message, parsedMessage);
|
|
1170
|
+
rememberThreadFromMessage("codex", message, parsedMessage);
|
|
1171
|
+
secureTransport.queueOutboundApplicationMessage(
|
|
1172
|
+
sanitizeRelayBoundCodexMessage(message, parsedMessage),
|
|
1173
|
+
sendRelayWireMessage
|
|
1174
|
+
);
|
|
731
1175
|
});
|
|
732
1176
|
|
|
733
1177
|
codex.onClose(() => {
|
|
734
1178
|
const wasShuttingDown = isShuttingDown;
|
|
735
1179
|
clearRelayWatchdog();
|
|
736
1180
|
bridgeStatusPublisher.stopHeartbeat();
|
|
1181
|
+
logConnectionStatus("disconnected");
|
|
737
1182
|
const lastError = wasShuttingDown
|
|
738
1183
|
? ""
|
|
739
|
-
:
|
|
740
|
-
logConnectionStatus("disconnected", lastError);
|
|
1184
|
+
: "Codex transport closed unexpectedly.";
|
|
741
1185
|
publishBridgeStatus({
|
|
742
1186
|
state: wasShuttingDown ? "stopped" : "error",
|
|
743
1187
|
connectionStatus: "disconnected",
|
|
@@ -752,44 +1196,42 @@ function startBridge({
|
|
|
752
1196
|
desktopRefresher.handleTransportReset();
|
|
753
1197
|
failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
|
|
754
1198
|
forwardedRequestMethodsById.clear();
|
|
755
|
-
codexResponseRoutesById.clear();
|
|
756
1199
|
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
|
|
757
1200
|
socket.close();
|
|
758
1201
|
}
|
|
759
|
-
closeExtraRelayChannels();
|
|
760
1202
|
});
|
|
761
1203
|
|
|
762
1204
|
process.on("SIGINT", () => shutdown(codex, () => socket, prepareBridgeShutdown));
|
|
763
1205
|
process.on("SIGTERM", () => shutdown(codex, () => socket, prepareBridgeShutdown));
|
|
764
1206
|
|
|
765
1207
|
// Routes decrypted app payloads through the same bridge handlers as before.
|
|
766
|
-
function handleApplicationMessage(rawMessage
|
|
767
|
-
const
|
|
768
|
-
if (handleBridgeManagedHandshakeMessage(rawMessage,
|
|
1208
|
+
function handleApplicationMessage(rawMessage) {
|
|
1209
|
+
const parsedMessage = parseBridgeMessage(rawMessage);
|
|
1210
|
+
if (handleBridgeManagedHandshakeMessage(rawMessage, sendApplicationResponse, parsedMessage)) {
|
|
769
1211
|
return;
|
|
770
1212
|
}
|
|
771
|
-
if (handleBridgeManagedAccountRequest(rawMessage,
|
|
1213
|
+
if (handleBridgeManagedAccountRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
|
|
772
1214
|
return;
|
|
773
1215
|
}
|
|
774
|
-
if (voiceHandler.handleVoiceRequest(rawMessage,
|
|
1216
|
+
if (voiceHandler.handleVoiceRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
|
|
775
1217
|
return;
|
|
776
1218
|
}
|
|
777
|
-
if (handleThreadContextRequest(rawMessage,
|
|
1219
|
+
if (handleThreadContextRequest(rawMessage, sendApplicationResponse, parsedMessage)) {
|
|
778
1220
|
return;
|
|
779
1221
|
}
|
|
780
|
-
if (handleWorkspaceRequest(rawMessage,
|
|
1222
|
+
if (handleWorkspaceRequest(rawMessage, sendApplicationResponse)) {
|
|
781
1223
|
return;
|
|
782
1224
|
}
|
|
783
|
-
if (handleProjectRequest(rawMessage,
|
|
1225
|
+
if (handleProjectRequest(rawMessage, sendApplicationResponse, { projectRegistry })) {
|
|
784
1226
|
return;
|
|
785
1227
|
}
|
|
786
|
-
if (handlePetRequest(rawMessage,
|
|
1228
|
+
if (handlePetRequest(rawMessage, sendApplicationResponse)) {
|
|
787
1229
|
return;
|
|
788
1230
|
}
|
|
789
|
-
if (notificationsHandler.handleNotificationsRequest(rawMessage,
|
|
1231
|
+
if (notificationsHandler.handleNotificationsRequest(rawMessage, sendApplicationResponse)) {
|
|
790
1232
|
return;
|
|
791
1233
|
}
|
|
792
|
-
if (handleDesktopRequest(rawMessage,
|
|
1234
|
+
if (handleDesktopRequest(rawMessage, sendApplicationResponse, {
|
|
793
1235
|
bundleId: config.codexBundleId,
|
|
794
1236
|
appPath: config.codexAppPath,
|
|
795
1237
|
readBridgePreferences,
|
|
@@ -797,200 +1239,102 @@ function startBridge({
|
|
|
797
1239
|
updateBridgePackageAndRestart,
|
|
798
1240
|
})) {
|
|
799
1241
|
return;
|
|
800
|
-
}
|
|
801
|
-
if (handleGitRequest(rawMessage,
|
|
802
|
-
codexAppPath: config.codexAppPath,
|
|
803
|
-
onThreadNameSet: sendThreadNameUpdatedNotification,
|
|
804
|
-
})) {
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
desktopRefresher.handleInbound(rawMessage);
|
|
808
|
-
rolloutLiveMirror?.observeInbound(rawMessage);
|
|
809
|
-
if (desktopIpcActionFollower?.observeInbound(rawMessage)) {
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendResponse)) {
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
const codexRequest = disableUnsupportedReasoningSummaryForTurnStart(rawMessage);
|
|
816
|
-
const codexMessage = prepareCodexForwardMessage(codexRequest, relayChannel);
|
|
817
|
-
rememberForwardedRequestMethod(codexMessage);
|
|
818
|
-
rememberThreadFromMessage("phone", codexMessage);
|
|
819
|
-
mirrorUserMessageToPeerDevices(rawMessage, relayChannel);
|
|
820
|
-
codex.send(codexMessage);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Encrypts bridge-generated responses instead of letting the relay see plaintext.
|
|
824
|
-
function sendApplicationResponse(rawMessage) {
|
|
825
|
-
sendApplicationResponseToChannels(rawMessage, relayChannels);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function sendApplicationResponseToChannel(rawMessage, relayChannel = primaryRelayChannel) {
|
|
829
|
-
sendApplicationResponseToChannels(rawMessage, [relayChannel]);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function sendApplicationResponseToChannels(rawMessage, channels) {
|
|
833
|
-
const normalizedChannels = channels.filter(Boolean);
|
|
834
|
-
if (normalizedChannels.length === 0) {
|
|
1242
|
+
}
|
|
1243
|
+
if (handleGitRequest(rawMessage, sendApplicationResponse, {
|
|
1244
|
+
codexAppPath: config.codexAppPath,
|
|
1245
|
+
onThreadNameSet: sendThreadNameUpdatedNotification,
|
|
1246
|
+
})) {
|
|
835
1247
|
return;
|
|
836
1248
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1249
|
+
desktopRefresher.handleInbound(rawMessage, parsedMessage);
|
|
1250
|
+
rolloutLiveMirror?.observeInbound(rawMessage, parsedMessage);
|
|
1251
|
+
// Track the request method BEFORE follower interception: responses the
|
|
1252
|
+
// follower serves from projected Desktop state must hit the same relay
|
|
1253
|
+
// sanitize/trim budget as app-server responses, or heavy threads ship as
|
|
1254
|
+
// one oversized frame and kill the phone's websocket (EMSGSIZE).
|
|
1255
|
+
rememberForwardedRequestMethod(rawMessage);
|
|
1256
|
+
if (desktopIpcActionFollower?.observeInbound(rawMessage, parsedMessage)) {
|
|
1257
|
+
return;
|
|
841
1258
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1259
|
+
if (runtimeProviderRouter.handleApplicationMessage(rawMessage, {
|
|
1260
|
+
sendResponse: sendApplicationResponse,
|
|
1261
|
+
})) {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage);
|
|
1265
|
+
if (handleBridgeManagedThreadTurnsListRequest(rawMessage, sendApplicationResponse)) {
|
|
846
1266
|
return;
|
|
847
1267
|
}
|
|
1268
|
+
forwardInboundRequestToCodex(rawMessage);
|
|
1269
|
+
}
|
|
848
1270
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1271
|
+
function forwardInboundRequestToCodex(rawMessage) {
|
|
1272
|
+
const codexRequest = stripRuntimeProviderFieldsForCodex(
|
|
1273
|
+
normalizeTurnStartForCodex(rawMessage)
|
|
852
1274
|
);
|
|
1275
|
+
rememberKnownProjectFromRequest("codex-request", codexRequest);
|
|
1276
|
+
rememberForwardedRequestMethod(rawMessage);
|
|
1277
|
+
rememberThreadFromMessage("phone", codexRequest);
|
|
1278
|
+
codex.send(codexRequest);
|
|
853
1279
|
}
|
|
854
1280
|
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
if (!
|
|
859
|
-
return
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
pruneExpiredCodexResponseRoutes();
|
|
863
|
-
const originalId = parsed.id;
|
|
864
|
-
const forwardedId = `mobile:${relayChannel.label}:${randomBytes(8).toString("hex")}`;
|
|
865
|
-
if (parsed.method === "initialize") {
|
|
866
|
-
forwardedInitializeRequestIds.delete(String(originalId));
|
|
867
|
-
forwardedInitializeRequestIds.add(String(forwardedId));
|
|
1281
|
+
// Held Desktop-ownership probes can later fall back locally, so observe each
|
|
1282
|
+
// phone request at most once in the live owner even if it passes both paths.
|
|
1283
|
+
function observeDesktopIpcLiveOwnerInbound(rawMessage, parsedMessage = null) {
|
|
1284
|
+
if (!desktopIpcLiveOwner) {
|
|
1285
|
+
return;
|
|
868
1286
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
originalId,
|
|
873
|
-
createdAt: Date.now(),
|
|
874
|
-
});
|
|
875
|
-
return JSON.stringify(parsed);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function sendCodexOutboundToMobile(rawMessage) {
|
|
879
|
-
pruneExpiredCodexResponseRoutes();
|
|
880
|
-
const parsed = safeParseJSON(rawMessage);
|
|
881
|
-
const responseId = parsed?.id;
|
|
882
|
-
if (responseId != null) {
|
|
883
|
-
const route = codexResponseRoutesById.get(String(responseId));
|
|
884
|
-
if (route) {
|
|
885
|
-
codexResponseRoutesById.delete(String(responseId));
|
|
886
|
-
const sanitizedMessage = sanitizeRelayBoundCodexMessage(rawMessage);
|
|
887
|
-
const sanitizedParsed = safeParseJSON(sanitizedMessage);
|
|
888
|
-
if (sanitizedParsed && typeof sanitizedParsed === "object") {
|
|
889
|
-
sanitizedParsed.id = route.originalId;
|
|
890
|
-
queueSanitizedApplicationMessageToChannel(JSON.stringify(sanitizedParsed), route.relayChannel);
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
queueSanitizedApplicationMessageToChannel(sanitizedMessage, route.relayChannel);
|
|
1287
|
+
const inboundKey = desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage);
|
|
1288
|
+
if (inboundKey) {
|
|
1289
|
+
if (desktopIpcLiveOwnerObservedInboundKeys.has(inboundKey)) {
|
|
894
1290
|
return;
|
|
895
1291
|
}
|
|
1292
|
+
desktopIpcLiveOwnerObservedInboundKeys.add(inboundKey);
|
|
1293
|
+
evictOldestEntries(desktopIpcLiveOwnerObservedInboundKeys, FORWARDED_REQUEST_METHODS_MAX_SIZE);
|
|
896
1294
|
}
|
|
897
|
-
|
|
898
|
-
sendApplicationResponse(rawMessage);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function pruneExpiredCodexResponseRoutes() {
|
|
902
|
-
const cutoff = Date.now() - forwardedRequestMethodTTLms;
|
|
903
|
-
for (const [requestId, route] of codexResponseRoutesById.entries()) {
|
|
904
|
-
if (!route || route.createdAt < cutoff) {
|
|
905
|
-
codexResponseRoutesById.delete(requestId);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
1295
|
+
desktopIpcLiveOwner.observeInbound(rawMessage, parsedMessage);
|
|
908
1296
|
}
|
|
909
1297
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
if (!
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const peerChannels = relayChannels.filter((relayChannel) => relayChannel !== originRelayChannel);
|
|
918
|
-
if (peerChannels.length === 0) {
|
|
919
|
-
return;
|
|
1298
|
+
function desktopIpcLiveOwnerInboundKey(rawMessage, parsedMessage = null) {
|
|
1299
|
+
const parsed = parsedMessage ?? safeParseJSON(rawMessage);
|
|
1300
|
+
const method = typeof parsed?.method === "string" ? parsed.method : "";
|
|
1301
|
+
if (!method || parsed?.id == null) {
|
|
1302
|
+
return "";
|
|
920
1303
|
}
|
|
921
|
-
|
|
922
|
-
|
|
1304
|
+
// extractThreadId only understands turn/thread start and completion params;
|
|
1305
|
+
// archive, steer, interrupt, and compact requests need the generic fields so
|
|
1306
|
+
// same-id requests for different threads never share a dedupe key.
|
|
1307
|
+
const threadId = extractThreadId(method, parsed.params)
|
|
1308
|
+
|| readString(parsed?.params?.threadId)
|
|
1309
|
+
|| readString(parsed?.params?.thread_id)
|
|
1310
|
+
|| readString(parsed?.params?.conversationId)
|
|
1311
|
+
|| readString(parsed?.params?.conversation_id)
|
|
1312
|
+
|| "";
|
|
1313
|
+
return `${method}:${threadId}:${String(parsed.id)}`;
|
|
923
1314
|
}
|
|
924
1315
|
|
|
925
|
-
function
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const params = parsed?.params && typeof parsed.params === "object" ? parsed.params : null;
|
|
933
|
-
const threadId = readString(params?.threadId || params?.thread_id);
|
|
934
|
-
const text = extractTextFromTurnPayload(params);
|
|
935
|
-
if (!threadId || !text) {
|
|
1316
|
+
function parseBridgeMessage(rawMessage) {
|
|
1317
|
+
try {
|
|
1318
|
+
return JSON.parse(rawMessage);
|
|
1319
|
+
} catch {
|
|
936
1320
|
return null;
|
|
937
1321
|
}
|
|
938
|
-
|
|
939
|
-
const turnId = readString(params?.turnId || params?.turn_id || params?.expectedTurnId || params?.expected_turn_id);
|
|
940
|
-
return {
|
|
941
|
-
method: "codex/event/user_message",
|
|
942
|
-
params: {
|
|
943
|
-
threadId,
|
|
944
|
-
thread_id: threadId,
|
|
945
|
-
turnId: turnId || undefined,
|
|
946
|
-
turn_id: turnId || undefined,
|
|
947
|
-
message: text,
|
|
948
|
-
text,
|
|
949
|
-
source: "peer-mobile",
|
|
950
|
-
},
|
|
951
|
-
};
|
|
952
1322
|
}
|
|
953
1323
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
return extractTextFromTurnInput(params?.input);
|
|
1324
|
+
// Encrypts bridge-generated responses instead of letting the relay see plaintext.
|
|
1325
|
+
function sendApplicationResponse(rawMessage) {
|
|
1326
|
+
secureTransport.queueOutboundApplicationMessage(
|
|
1327
|
+
sanitizeRelayBoundCodexMessage(rawMessage),
|
|
1328
|
+
sendRelayWireMessage
|
|
1329
|
+
);
|
|
961
1330
|
}
|
|
962
1331
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const directText = readString(input.text || input.message || input.content);
|
|
970
|
-
if (directText) {
|
|
971
|
-
return directText;
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
const inputItems = Array.isArray(input)
|
|
976
|
-
? input
|
|
977
|
-
: Array.isArray(input?.items)
|
|
978
|
-
? input.items
|
|
979
|
-
: [];
|
|
980
|
-
const textParts = [];
|
|
981
|
-
for (const item of inputItems) {
|
|
982
|
-
if (!item || typeof item !== "object") {
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const itemType = readString(item.type).toLowerCase();
|
|
987
|
-
const itemText = readString(item.text || item.message || item.content);
|
|
988
|
-
if ((itemType === "text" || itemType === "input_text" || itemType === "message") && itemText) {
|
|
989
|
-
textParts.push(itemText);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
return readString(textParts.join("\n\n"));
|
|
1332
|
+
// Provider output keeps the same desktop refresh, push, and secure relay side effects as Codex output.
|
|
1333
|
+
function sendRuntimeApplicationMessage(provider, rawMessage) {
|
|
1334
|
+
desktopRefresher.handleOutbound(rawMessage);
|
|
1335
|
+
pushNotificationTracker.handleOutbound(rawMessage);
|
|
1336
|
+
rememberThreadFromMessage(provider, rawMessage);
|
|
1337
|
+
sendApplicationResponse(rawMessage);
|
|
994
1338
|
}
|
|
995
1339
|
|
|
996
1340
|
// Mirrors accepted local renames back to the phone using the existing push-event shape.
|
|
@@ -1020,25 +1364,41 @@ function startBridge({
|
|
|
1020
1364
|
|
|
1021
1365
|
rememberThreadFromMessage("phone", rawMessage);
|
|
1022
1366
|
(async () => {
|
|
1367
|
+
let didRespond = false;
|
|
1368
|
+
const respondOnce = (payload) => {
|
|
1369
|
+
if (didRespond) {
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
didRespond = true;
|
|
1373
|
+
sendResponse(payload);
|
|
1374
|
+
};
|
|
1023
1375
|
try {
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1376
|
+
const selection = await threadTurnsListFastPageCoordinator.resolve(request, {
|
|
1377
|
+
fetchCanonical: (canonicalRequest) => fetchAdaptiveThreadTurnsListForRelay(canonicalRequest, {
|
|
1378
|
+
fetchPage: (params) => sendCodexRequest("thread/turns/list", params),
|
|
1379
|
+
}),
|
|
1380
|
+
readJsonl: (jsonlRequest) => maybeBuildJsonlThreadTurnsListFallback(jsonlRequest, null),
|
|
1026
1381
|
});
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1382
|
+
let responsePayload = selection.response;
|
|
1383
|
+
if (selection.source === "canonical" && selection.jsonlFallback?.response?.result) {
|
|
1384
|
+
responsePayload = maybeMergeLatestJsonlTurnIntoTurnsListResponse(
|
|
1385
|
+
request,
|
|
1386
|
+
selection.response,
|
|
1387
|
+
selection.jsonlFallback.response.result
|
|
1388
|
+
) || selection.response;
|
|
1389
|
+
}
|
|
1390
|
+
sendBridgeManagedThreadTurnsListResponse(request, responsePayload, respondOnce, {
|
|
1391
|
+
skipJsonlArtifactAugmentation: selection.usesJsonl,
|
|
1034
1392
|
});
|
|
1035
|
-
sendResponse(sanitizeThreadHistoryImagesForRelay(
|
|
1036
|
-
JSON.stringify(responsePayload),
|
|
1037
|
-
"thread/turns/list",
|
|
1038
|
-
finalSanitizeContext
|
|
1039
|
-
));
|
|
1040
1393
|
} catch (error) {
|
|
1041
|
-
|
|
1394
|
+
const jsonlFallback = maybeBuildJsonlThreadTurnsListFallback(request, null);
|
|
1395
|
+
if (jsonlFallback?.response && isCoherentJsonlFirstPageResponse(jsonlFallback.response)) {
|
|
1396
|
+
sendBridgeManagedThreadTurnsListResponse(request, jsonlFallback.response, respondOnce, {
|
|
1397
|
+
skipJsonlArtifactAugmentation: true,
|
|
1398
|
+
});
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
respondOnce(createJsonRpcErrorResponse(
|
|
1042
1402
|
request.id,
|
|
1043
1403
|
error,
|
|
1044
1404
|
"thread_turns_list_failed"
|
|
@@ -1049,16 +1409,34 @@ function startBridge({
|
|
|
1049
1409
|
return true;
|
|
1050
1410
|
}
|
|
1051
1411
|
|
|
1412
|
+
function sendBridgeManagedThreadTurnsListResponse(request, response, sendResponse, {
|
|
1413
|
+
skipJsonlArtifactAugmentation = false,
|
|
1414
|
+
} = {}) {
|
|
1415
|
+
const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
|
|
1416
|
+
skipJsonlArtifactAugmentation,
|
|
1417
|
+
});
|
|
1418
|
+
relaySanitizedResponseMethodsById.set(String(request.id), {
|
|
1419
|
+
method: "thread/turns/list",
|
|
1420
|
+
...finalSanitizeContext,
|
|
1421
|
+
createdAt: Date.now(),
|
|
1422
|
+
});
|
|
1423
|
+
sendResponse(sanitizeThreadHistoryImagesForRelay(
|
|
1424
|
+
JSON.stringify(response),
|
|
1425
|
+
"thread/turns/list",
|
|
1426
|
+
finalSanitizeContext
|
|
1427
|
+
));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1052
1430
|
function maybeBuildJsonlThreadTurnsListFallback(request, response) {
|
|
1053
1431
|
const params = request?.params || {};
|
|
1054
1432
|
const threadId = normalizeNonEmptyString(params.threadId)
|
|
1055
1433
|
|| normalizeNonEmptyString(params.thread_id);
|
|
1056
|
-
if (!threadId || hasRelayCursor(params.cursor)) {
|
|
1434
|
+
if (!threadId || hasRelayCursor(params.cursor) || params.remodexRequireCanonical === true) {
|
|
1057
1435
|
return null;
|
|
1058
1436
|
}
|
|
1059
1437
|
|
|
1060
1438
|
try {
|
|
1061
|
-
const responseIsEmpty = isEmptyTurnsListResponse(response);
|
|
1439
|
+
const responseIsEmpty = response == null || isEmptyTurnsListResponse(response);
|
|
1062
1440
|
const rolloutPath = resolveJsonlTurnsListRolloutPathForFallback({
|
|
1063
1441
|
threadId,
|
|
1064
1442
|
responseIsEmpty,
|
|
@@ -1069,10 +1447,20 @@ function startBridge({
|
|
|
1069
1447
|
return null;
|
|
1070
1448
|
}
|
|
1071
1449
|
|
|
1450
|
+
// A first page is the local baseline for a newly opened thread. Honor a
|
|
1451
|
+
// caller's larger request, but never manufacture the old one-turn tail:
|
|
1452
|
+
// it has no room to preserve surrounding history while canonical data is
|
|
1453
|
+
// still catching up.
|
|
1454
|
+
const requestedLimit = Number.isInteger(params.limit) && params.limit > 0
|
|
1455
|
+
? params.limit
|
|
1456
|
+
: RELAY_TURNS_LIST_MAX_INITIAL_LIMIT;
|
|
1457
|
+
const firstPageLimit = params.cursor == null
|
|
1458
|
+
? Math.max(requestedLimit, RELAY_TURNS_LIST_MAX_INITIAL_LIMIT)
|
|
1459
|
+
: requestedLimit;
|
|
1072
1460
|
const result = readThreadTurnsListPageFromSessionJsonl(rolloutPath, {
|
|
1073
1461
|
threadId,
|
|
1074
|
-
limit:
|
|
1075
|
-
maxLimit:
|
|
1462
|
+
limit: firstPageLimit,
|
|
1463
|
+
maxLimit: RELAY_TURNS_LIST_MAX_INITIAL_LIMIT,
|
|
1076
1464
|
cursor: params.cursor,
|
|
1077
1465
|
});
|
|
1078
1466
|
const turnsKey = findTurnsListResultKey(result);
|
|
@@ -1081,7 +1469,7 @@ function startBridge({
|
|
|
1081
1469
|
}
|
|
1082
1470
|
|
|
1083
1471
|
if (!responseIsEmpty) {
|
|
1084
|
-
const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result
|
|
1472
|
+
const mergedResponse = maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, result);
|
|
1085
1473
|
return mergedResponse ? { response: mergedResponse, usesJsonl: true } : null;
|
|
1086
1474
|
}
|
|
1087
1475
|
|
|
@@ -1147,11 +1535,9 @@ function startBridge({
|
|
|
1147
1535
|
|
|
1148
1536
|
// Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
|
|
1149
1537
|
// This dispatcher stays synchronous so non-account messages can continue down the normal routing chain.
|
|
1150
|
-
function handleBridgeManagedAccountRequest(rawMessage, sendResponse) {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
parsed = JSON.parse(rawMessage);
|
|
1154
|
-
} catch {
|
|
1538
|
+
function handleBridgeManagedAccountRequest(rawMessage, sendResponse, parsedMessage = null) {
|
|
1539
|
+
const parsed = parsedMessage || parseBridgeMessage(rawMessage);
|
|
1540
|
+
if (!parsed) {
|
|
1155
1541
|
return false;
|
|
1156
1542
|
}
|
|
1157
1543
|
|
|
@@ -1306,16 +1692,30 @@ function startBridge({
|
|
|
1306
1692
|
}
|
|
1307
1693
|
|
|
1308
1694
|
// Replaces huge inline desktop-history images with lightweight references before relay encryption.
|
|
1309
|
-
function sanitizeRelayBoundCodexMessage(rawMessage) {
|
|
1695
|
+
function sanitizeRelayBoundCodexMessage(rawMessage, parsedMessage = null) {
|
|
1310
1696
|
pruneExpiredForwardedRequestMethods();
|
|
1311
|
-
|
|
1697
|
+
let normalizedMessage = normalizeRelayBoundJsonRpcMessage(rawMessage, {
|
|
1312
1698
|
pendingRequestMethodsById: relaySanitizedResponseMethodsById,
|
|
1699
|
+
parsedMessage,
|
|
1313
1700
|
});
|
|
1314
1701
|
if (!normalizedMessage) {
|
|
1315
1702
|
return null;
|
|
1316
1703
|
}
|
|
1317
1704
|
|
|
1318
|
-
|
|
1705
|
+
// Streaming deltas hit this path dozens of times per second; when the
|
|
1706
|
+
// envelope passed through normalization untouched, reuse the parse the
|
|
1707
|
+
// caller already paid for instead of re-parsing the same bytes.
|
|
1708
|
+
let parsed = normalizedMessage === rawMessage && parsedMessage
|
|
1709
|
+
? parsedMessage
|
|
1710
|
+
: safeParseJSON(normalizedMessage);
|
|
1711
|
+
const sanitizedLiveMessage = sanitizeLiveUserNotification(parsed);
|
|
1712
|
+
if (!sanitizedLiveMessage) {
|
|
1713
|
+
return null;
|
|
1714
|
+
}
|
|
1715
|
+
if (sanitizedLiveMessage !== parsed) {
|
|
1716
|
+
parsed = sanitizedLiveMessage;
|
|
1717
|
+
normalizedMessage = JSON.stringify(parsed);
|
|
1718
|
+
}
|
|
1319
1719
|
const responseId = parsed?.id;
|
|
1320
1720
|
if (responseId == null) {
|
|
1321
1721
|
return sanitizeLiveGeneratedImageMessageForRelay(normalizedMessage);
|
|
@@ -1327,12 +1727,19 @@ function startBridge({
|
|
|
1327
1727
|
}
|
|
1328
1728
|
relaySanitizedResponseMethodsById.delete(String(responseId));
|
|
1329
1729
|
|
|
1730
|
+
if (trackedRequest.method === "thread/list"
|
|
1731
|
+
|| trackedRequest.method === "thread/read"
|
|
1732
|
+
|| trackedRequest.method === "thread/resume") {
|
|
1733
|
+
threadRuntimeSettingsStore.enrichResponse(trackedRequest.method, parsed);
|
|
1734
|
+
normalizedMessage = JSON.stringify(parsed);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1330
1737
|
return sanitizeThreadHistoryImagesForRelay(normalizedMessage, trackedRequest.method, trackedRequest);
|
|
1331
1738
|
}
|
|
1332
1739
|
|
|
1333
|
-
function updatePendingAuthLoginFromCodexMessage(rawMessage) {
|
|
1740
|
+
function updatePendingAuthLoginFromCodexMessage(rawMessage, parsedMessage = null) {
|
|
1334
1741
|
pruneExpiredForwardedRequestMethods();
|
|
1335
|
-
const parsed = safeParseJSON(rawMessage);
|
|
1742
|
+
const parsed = parsedMessage ?? safeParseJSON(rawMessage);
|
|
1336
1743
|
const responseId = parsed?.id;
|
|
1337
1744
|
if (responseId != null) {
|
|
1338
1745
|
const trackedRequest = forwardedRequestMethodsById.get(String(responseId));
|
|
@@ -1405,6 +1812,7 @@ function startBridge({
|
|
|
1405
1812
|
evictOldestEntries(jsonlArtifactItemsCacheByThread, RELAY_JSONL_ARTIFACT_CACHE_MAX_ENTRIES);
|
|
1406
1813
|
evictOldestEntries(jsonlTurnsListRolloutCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
|
|
1407
1814
|
evictOldestEntries(jsonlTurnsListRolloutMissCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
|
|
1815
|
+
evictOldestEntries(jsonlThreadCwdCacheByThread, JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE);
|
|
1408
1816
|
}
|
|
1409
1817
|
|
|
1410
1818
|
function safeParseJSON(value) {
|
|
@@ -1415,8 +1823,8 @@ function startBridge({
|
|
|
1415
1823
|
}
|
|
1416
1824
|
}
|
|
1417
1825
|
|
|
1418
|
-
function rememberThreadFromMessage(source, rawMessage) {
|
|
1419
|
-
const context = extractBridgeMessageContext(rawMessage);
|
|
1826
|
+
function rememberThreadFromMessage(source, rawMessage, parsedMessage = null) {
|
|
1827
|
+
const context = extractBridgeMessageContext(rawMessage, parsedMessage);
|
|
1420
1828
|
if (!context.threadId) {
|
|
1421
1829
|
return;
|
|
1422
1830
|
}
|
|
@@ -1427,6 +1835,33 @@ function startBridge({
|
|
|
1427
1835
|
}
|
|
1428
1836
|
}
|
|
1429
1837
|
|
|
1838
|
+
// Captures explicit cwd selections before Codex creates the first thread, so
|
|
1839
|
+
// provider-neutral pickers do not depend on a later provider-specific message.
|
|
1840
|
+
function rememberKnownProjectFromRequest(source, rawMessage) {
|
|
1841
|
+
const parsed = safeParseJSON(rawMessage);
|
|
1842
|
+
const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
|
|
1843
|
+
if (method !== "thread/start" && method !== "turn/start") {
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const params = parsed?.params || {};
|
|
1848
|
+
const cwd = normalizeNonEmptyString(
|
|
1849
|
+
params.cwd || params.current_working_directory || params.working_directory
|
|
1850
|
+
);
|
|
1851
|
+
if (!cwd) {
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
try {
|
|
1856
|
+
projectRegistry.rememberProjectPath(cwd, {
|
|
1857
|
+
source,
|
|
1858
|
+
provider: "codex",
|
|
1859
|
+
});
|
|
1860
|
+
} catch {
|
|
1861
|
+
// Registry persistence is best-effort; thread creation must keep flowing.
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1430
1865
|
// Mirrors CodexMonitor's persisted token_count fallback so the phone keeps
|
|
1431
1866
|
// receiving context-window usage even when the runtime omits live thread usage.
|
|
1432
1867
|
function ensureContextUsageWatcher({ threadId, turnId }) {
|
|
@@ -1493,11 +1928,9 @@ function startBridge({
|
|
|
1493
1928
|
// The spawned/shared Codex app-server stays warm across phone reconnects.
|
|
1494
1929
|
// When iPhone reconnects it sends initialize again, but forwarding that to the
|
|
1495
1930
|
// already-initialized Codex transport only produces "Already initialized".
|
|
1496
|
-
function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
parsed = JSON.parse(rawMessage);
|
|
1500
|
-
} catch {
|
|
1931
|
+
function handleBridgeManagedHandshakeMessage(rawMessage, sendResponse = sendApplicationResponse, parsedMessage = null) {
|
|
1932
|
+
const parsed = parsedMessage || parseBridgeMessage(rawMessage);
|
|
1933
|
+
if (!parsed) {
|
|
1501
1934
|
return false;
|
|
1502
1935
|
}
|
|
1503
1936
|
|
|
@@ -1602,11 +2035,9 @@ function startBridge({
|
|
|
1602
2035
|
}
|
|
1603
2036
|
|
|
1604
2037
|
// Learns whether the underlying Codex transport has already completed its own MCP handshake.
|
|
1605
|
-
function trackCodexHandshakeState(rawMessage) {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
parsed = JSON.parse(rawMessage);
|
|
1609
|
-
} catch {
|
|
2038
|
+
function trackCodexHandshakeState(rawMessage, parsedMessage = null) {
|
|
2039
|
+
const parsed = parsedMessage ?? safeParseJSON(rawMessage);
|
|
2040
|
+
if (!parsed) {
|
|
1610
2041
|
return;
|
|
1611
2042
|
}
|
|
1612
2043
|
|
|
@@ -1670,11 +2101,9 @@ function startBridge({
|
|
|
1670
2101
|
|
|
1671
2102
|
// Intercepts responses for bridge-private requests so only user-visible app-server traffic
|
|
1672
2103
|
// is forwarded back through secure transport.
|
|
1673
|
-
function handleBridgeManagedCodexResponse(rawMessage) {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
parsed = JSON.parse(rawMessage);
|
|
1677
|
-
} catch {
|
|
2104
|
+
function handleBridgeManagedCodexResponse(rawMessage, parsedMessage = null) {
|
|
2105
|
+
const parsed = parsedMessage ?? safeParseJSON(rawMessage);
|
|
2106
|
+
if (!parsed) {
|
|
1678
2107
|
return false;
|
|
1679
2108
|
}
|
|
1680
2109
|
|
|
@@ -1734,11 +2163,6 @@ function startBridge({
|
|
|
1734
2163
|
function sendRelayRegistrationUpdate(nextDeviceState) {
|
|
1735
2164
|
deviceState = nextDeviceState;
|
|
1736
2165
|
if (socket?.readyState !== WebSocket.OPEN) {
|
|
1737
|
-
for (const relayChannel of relayChannels) {
|
|
1738
|
-
if (relayChannel !== primaryRelayChannel) {
|
|
1739
|
-
sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
2166
|
return;
|
|
1743
2167
|
}
|
|
1744
2168
|
|
|
@@ -1746,11 +2170,6 @@ function startBridge({
|
|
|
1746
2170
|
kind: "relayMacRegistration",
|
|
1747
2171
|
registration: buildMacRegistration(nextDeviceState, pairingSession),
|
|
1748
2172
|
}));
|
|
1749
|
-
for (const relayChannel of relayChannels) {
|
|
1750
|
-
if (relayChannel !== primaryRelayChannel) {
|
|
1751
|
-
sendExtraRelayRegistrationUpdate(relayChannel, nextDeviceState);
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
2173
|
}
|
|
1755
2174
|
|
|
1756
2175
|
function readBridgePreferences() {
|
|
@@ -1832,6 +2251,9 @@ function startBridge({
|
|
|
1832
2251
|
stdio: "ignore",
|
|
1833
2252
|
env: process.env,
|
|
1834
2253
|
});
|
|
2254
|
+
child.on?.("error", (error) => {
|
|
2255
|
+
console.warn(`[remodex] Failed to schedule the post-update bridge restart: ${error?.message || error}`);
|
|
2256
|
+
});
|
|
1835
2257
|
child.unref?.();
|
|
1836
2258
|
}, BRIDGE_RESTART_AFTER_UPDATE_DELAY_MS);
|
|
1837
2259
|
restartTimer.unref?.();
|
|
@@ -1932,7 +2354,7 @@ function createMacOSBridgeWakeAssertion({
|
|
|
1932
2354
|
};
|
|
1933
2355
|
}
|
|
1934
2356
|
|
|
1935
|
-
// Registers the canonical Mac identity
|
|
2357
|
+
// Registers the canonical Mac identity and the one trusted phone allowed for auto-resolve.
|
|
1936
2358
|
function buildMacRegistrationHeaders(deviceState, pairingSession) {
|
|
1937
2359
|
const registration = buildMacRegistration(deviceState, pairingSession);
|
|
1938
2360
|
const headers = {
|
|
@@ -1950,20 +2372,6 @@ function buildMacRegistrationHeaders(deviceState, pairingSession) {
|
|
|
1950
2372
|
return headers;
|
|
1951
2373
|
}
|
|
1952
2374
|
|
|
1953
|
-
function readExtraRelaySessionCount(env = process.env) {
|
|
1954
|
-
const rawValue = readString(env.REMODEX_EXTRA_RELAY_SESSIONS || env.PHODEX_EXTRA_RELAY_SESSIONS);
|
|
1955
|
-
if (!rawValue) {
|
|
1956
|
-
return 0;
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
const parsed = Number.parseInt(rawValue, 10);
|
|
1960
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1961
|
-
return 0;
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
return Math.min(parsed, 3);
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
2375
|
function buildMacRegistration(deviceState, pairingSession) {
|
|
1968
2376
|
const trustedPhoneEntry = Object.entries(deviceState?.trustedPhones || {})[0] || null;
|
|
1969
2377
|
return {
|
|
@@ -2021,7 +2429,7 @@ function shortFingerprint(value) {
|
|
|
2021
2429
|
return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
|
|
2022
2430
|
}
|
|
2023
2431
|
|
|
2024
|
-
function shutdown(codex, getSocket, beforeExit = () => {}
|
|
2432
|
+
function shutdown(codex, getSocket, beforeExit = () => {}) {
|
|
2025
2433
|
beforeExit();
|
|
2026
2434
|
|
|
2027
2435
|
const socket = getSocket();
|
|
@@ -2031,41 +2439,7 @@ function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}
|
|
|
2031
2439
|
|
|
2032
2440
|
codex.shutdown();
|
|
2033
2441
|
|
|
2034
|
-
setTimeout(() => process.exit(
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
function isTerminalRelayCloseCode(closeCode) {
|
|
2038
|
-
return closeCode === CLOSE_CODE_INVALID_RELAY_REQUEST
|
|
2039
|
-
|| closeCode === CLOSE_CODE_REPLACED_BY_NEW_MAC
|
|
2040
|
-
|| closeCode === CLOSE_CODE_MAC_UNAUTHORIZED;
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
function normalizeWebSocketCloseReason(reason) {
|
|
2044
|
-
if (typeof reason === "string") {
|
|
2045
|
-
return reason.trim();
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
if (Buffer.isBuffer(reason)) {
|
|
2049
|
-
return reason.toString("utf8").trim();
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
return "";
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
function buildRelayCloseStatusError(closeCode, closeReason = "") {
|
|
2056
|
-
if (!Number.isInteger(closeCode) || closeCode === 1000 || closeCode === 1005) {
|
|
2057
|
-
return "";
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
const normalizedReason = normalizeNonEmptyString(closeReason);
|
|
2061
|
-
if (closeCode === CLOSE_CODE_MAC_UNAUTHORIZED) {
|
|
2062
|
-
return normalizedReason
|
|
2063
|
-
|| "Relay authorization failed. Set REMODEX_RELAY_ACCESS_TOKEN or use a relay that does not require a Mac access token.";
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
return normalizedReason
|
|
2067
|
-
? `Relay closed the connection (${closeCode}): ${normalizedReason}`
|
|
2068
|
-
: `Relay closed the connection (${closeCode}).`;
|
|
2442
|
+
setTimeout(() => process.exit(0), 100);
|
|
2069
2443
|
}
|
|
2070
2444
|
|
|
2071
2445
|
// Forces app-server summary generation off for models whose Responses API calls
|
|
@@ -2097,17 +2471,86 @@ function disableUnsupportedReasoningSummaryForTurnStart(rawMessage) {
|
|
|
2097
2471
|
});
|
|
2098
2472
|
}
|
|
2099
2473
|
|
|
2474
|
+
function normalizeTurnStartParamsForCodex(params) {
|
|
2475
|
+
const normalizedRawMessage = normalizeTurnStartForCodex(JSON.stringify({
|
|
2476
|
+
method: "turn/start",
|
|
2477
|
+
params,
|
|
2478
|
+
}));
|
|
2479
|
+
const parsed = parseBridgeJSON(normalizedRawMessage);
|
|
2480
|
+
return parsed?.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
|
|
2481
|
+
? parsed.params
|
|
2482
|
+
: params;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// A turn/start can carry the same runtime choice twice: in the legacy top-level
|
|
2486
|
+
// model/effort fields and in collaborationMode.settings. Codex treats the nested
|
|
2487
|
+
// collaboration settings as authoritative, so a stale Desktop value there can
|
|
2488
|
+
// silently override the model selected on the phone. Keep both representations
|
|
2489
|
+
// aligned before either direct app-server forwarding or Desktop-follower routing.
|
|
2490
|
+
function normalizeTurnStartForCodex(rawMessage) {
|
|
2491
|
+
const parsed = parseBridgeJSON(rawMessage);
|
|
2492
|
+
if (!parsed || parsed.method !== "turn/start") {
|
|
2493
|
+
return rawMessage;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
const params = parsed.params && typeof parsed.params === "object" && !Array.isArray(parsed.params)
|
|
2497
|
+
? parsed.params
|
|
2498
|
+
: null;
|
|
2499
|
+
if (!params) {
|
|
2500
|
+
return rawMessage;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
const model = normalizeNonEmptyString(params.model);
|
|
2504
|
+
const effort = normalizeNonEmptyString(params.effort);
|
|
2505
|
+
let changed = false;
|
|
2506
|
+
let nextParams = params;
|
|
2507
|
+
|
|
2508
|
+
for (const collaborationKey of ["collaborationMode", "collaboration_mode"]) {
|
|
2509
|
+
const collaborationMode = nextParams[collaborationKey];
|
|
2510
|
+
const settings = collaborationMode?.settings;
|
|
2511
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const nextSettings = { ...settings };
|
|
2516
|
+
let settingsChanged = false;
|
|
2517
|
+
if (model && normalizeNonEmptyString(settings.model) !== model) {
|
|
2518
|
+
nextSettings.model = model;
|
|
2519
|
+
settingsChanged = true;
|
|
2520
|
+
}
|
|
2521
|
+
if (effort && normalizeNonEmptyString(settings.reasoning_effort) !== effort) {
|
|
2522
|
+
nextSettings.reasoning_effort = effort;
|
|
2523
|
+
settingsChanged = true;
|
|
2524
|
+
}
|
|
2525
|
+
if (!settingsChanged) {
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
nextParams = {
|
|
2530
|
+
...nextParams,
|
|
2531
|
+
[collaborationKey]: {
|
|
2532
|
+
...collaborationMode,
|
|
2533
|
+
settings: nextSettings,
|
|
2534
|
+
},
|
|
2535
|
+
};
|
|
2536
|
+
changed = true;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
const alignedRawMessage = changed
|
|
2540
|
+
? JSON.stringify({ ...parsed, params: nextParams })
|
|
2541
|
+
: rawMessage;
|
|
2542
|
+
return disableUnsupportedReasoningSummaryForTurnStart(alignedRawMessage);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2100
2545
|
function readTurnStartModel(params) {
|
|
2101
2546
|
return normalizeNonEmptyString(params?.model).toLowerCase()
|
|
2102
2547
|
|| normalizeNonEmptyString(params?.collaborationMode?.settings?.model).toLowerCase()
|
|
2103
2548
|
|| normalizeNonEmptyString(params?.collaboration_mode?.settings?.model).toLowerCase();
|
|
2104
2549
|
}
|
|
2105
2550
|
|
|
2106
|
-
function extractBridgeMessageContext(rawMessage) {
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
parsed = JSON.parse(rawMessage);
|
|
2110
|
-
} catch {
|
|
2551
|
+
function extractBridgeMessageContext(rawMessage, parsedMessage = null) {
|
|
2552
|
+
const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
|
|
2553
|
+
if (!parsed) {
|
|
2111
2554
|
return { method: "", threadId: null, turnId: null };
|
|
2112
2555
|
}
|
|
2113
2556
|
|
|
@@ -2269,6 +2712,7 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
|
2269
2712
|
const remaining = requestedLimit - combinedTurns.length;
|
|
2270
2713
|
const pageLimit = selectAdaptiveTurnsListBatchLimit(combinedTurns.length, remaining);
|
|
2271
2714
|
const pageParams = buildAdaptiveTurnsListPageParams(params, pageLimit, nextCursor);
|
|
2715
|
+
const responseBeforePage = response;
|
|
2272
2716
|
let page;
|
|
2273
2717
|
|
|
2274
2718
|
try {
|
|
@@ -2314,7 +2758,31 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
|
2314
2758
|
response = buildSafeTurnsListResponse(request.id, firstResult, lastResult, turnsKey, combinedTurns);
|
|
2315
2759
|
|
|
2316
2760
|
if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) >= payloadSoftLimitBytes) {
|
|
2317
|
-
|
|
2761
|
+
if (responseBeforePage) {
|
|
2762
|
+
// The server cursor belongs after the entire oversized batch. Return
|
|
2763
|
+
// the previous complete cursor boundary instead of slicing turns out
|
|
2764
|
+
// of this batch and making them unreachable.
|
|
2765
|
+
response = responseBeforePage;
|
|
2766
|
+
break;
|
|
2767
|
+
}
|
|
2768
|
+
if (pageTurns.length > pageLimit) {
|
|
2769
|
+
const completeResponse = buildCompactedCompleteTurnsListResponse({
|
|
2770
|
+
requestId: request.id,
|
|
2771
|
+
firstResult,
|
|
2772
|
+
lastResult,
|
|
2773
|
+
turnsKey,
|
|
2774
|
+
turns: pageTurns,
|
|
2775
|
+
sanitizeForRelay,
|
|
2776
|
+
sanitizeContext,
|
|
2777
|
+
payloadSoftLimitBytes,
|
|
2778
|
+
});
|
|
2779
|
+
if (!completeResponse) {
|
|
2780
|
+
throw new Error("thread/turns/list returned an oversized batch without a safe cursor boundary.");
|
|
2781
|
+
}
|
|
2782
|
+
response = completeResponse;
|
|
2783
|
+
break;
|
|
2784
|
+
}
|
|
2785
|
+
const boundedResponse = buildLargestSafeTurnsListResponse({
|
|
2318
2786
|
requestId: request.id,
|
|
2319
2787
|
firstResult,
|
|
2320
2788
|
lastResult,
|
|
@@ -2324,7 +2792,11 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
|
2324
2792
|
sanitizeForRelay,
|
|
2325
2793
|
sanitizeContext,
|
|
2326
2794
|
payloadSoftLimitBytes,
|
|
2327
|
-
})
|
|
2795
|
+
});
|
|
2796
|
+
if (!boundedResponse) {
|
|
2797
|
+
throw new Error("The newest chat turn is too large to relay safely.");
|
|
2798
|
+
}
|
|
2799
|
+
response = boundedResponse;
|
|
2328
2800
|
break;
|
|
2329
2801
|
}
|
|
2330
2802
|
|
|
@@ -2347,22 +2819,10 @@ async function fetchAdaptiveThreadTurnsListForRelay(request, {
|
|
|
2347
2819
|
}
|
|
2348
2820
|
}
|
|
2349
2821
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
},
|
|
2355
|
-
};
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
function buildEmptyTurnsListResponse(request) {
|
|
2359
|
-
return {
|
|
2360
|
-
id: request.id,
|
|
2361
|
-
result: {
|
|
2362
|
-
data: [],
|
|
2363
|
-
nextCursor: null,
|
|
2364
|
-
},
|
|
2365
|
-
};
|
|
2822
|
+
if (!response) {
|
|
2823
|
+
throw new Error("thread/turns/list completed without a relayable page.");
|
|
2824
|
+
}
|
|
2825
|
+
return response;
|
|
2366
2826
|
}
|
|
2367
2827
|
|
|
2368
2828
|
function isEmptyTurnsListResponse(response) {
|
|
@@ -2391,7 +2851,7 @@ function resolveJsonlTurnsListRolloutPathForFallback({
|
|
|
2391
2851
|
: findAndCachePath(threadId);
|
|
2392
2852
|
}
|
|
2393
2853
|
|
|
2394
|
-
function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult
|
|
2854
|
+
function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonlResult) {
|
|
2395
2855
|
const responseResult = response?.result;
|
|
2396
2856
|
const responseTurnsKey = findTurnsListResultKey(responseResult);
|
|
2397
2857
|
const jsonlTurnsKey = findTurnsListResultKey(jsonlResult);
|
|
@@ -2410,16 +2870,17 @@ function maybeMergeLatestJsonlTurnIntoTurnsListResponse(request, response, jsonl
|
|
|
2410
2870
|
return null;
|
|
2411
2871
|
}
|
|
2412
2872
|
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
const mergedTurns = [jsonlTurn, ...responseTurns]
|
|
2873
|
+
// Keep the canonical page intact. Slicing this back to the requested limit
|
|
2874
|
+
// can retain the newer JSONL turn while dropping the canonical cursor anchor,
|
|
2875
|
+
// making that canonical turn permanently unreachable.
|
|
2876
|
+
const mergedTurns = [jsonlTurn, ...responseTurns];
|
|
2417
2877
|
return {
|
|
2418
2878
|
id: request.id,
|
|
2419
2879
|
result: {
|
|
2420
2880
|
...responseResult,
|
|
2421
2881
|
[responseTurnsKey]: mergedTurns,
|
|
2422
2882
|
remodexJsonlMergedLatest: true,
|
|
2883
|
+
remodexJsonlFallback: true,
|
|
2423
2884
|
},
|
|
2424
2885
|
};
|
|
2425
2886
|
}
|
|
@@ -2446,6 +2907,10 @@ function turnListTurnIdentifier(turn) {
|
|
|
2446
2907
|
|| normalizeNonEmptyString(turn?.turn_id);
|
|
2447
2908
|
}
|
|
2448
2909
|
|
|
2910
|
+
function isSyntheticJsonlHistoryTurnId(turnId) {
|
|
2911
|
+
return normalizeNonEmptyString(turnId).startsWith("turn-line-");
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2449
2914
|
async function fetchSafeThreadTurnsListFallback(request, {
|
|
2450
2915
|
fetchPage,
|
|
2451
2916
|
now,
|
|
@@ -2460,35 +2925,29 @@ async function fetchSafeThreadTurnsListFallback(request, {
|
|
|
2460
2925
|
const safeLimit = Math.min(requestedLimit, RELAY_TURNS_LIST_SAFE_RETRY_LIMIT);
|
|
2461
2926
|
const safeParams = buildAdaptiveTurnsListPageParams(params, safeLimit, params?.cursor);
|
|
2462
2927
|
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
return buildEmptyTurnsListResponse(request);
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
// If the normal pagination path returns a bad first page, retry once with a small page.
|
|
2472
|
-
// The retry response is intentionally minimal so Swift does not decode stale server metadata.
|
|
2473
|
-
const response = buildLargestSafeTurnsListResponse({
|
|
2474
|
-
requestId: request.id,
|
|
2475
|
-
firstResult: pageResult,
|
|
2476
|
-
lastResult: pageResult,
|
|
2477
|
-
turnsKey,
|
|
2478
|
-
turns: pageResult[turnsKey],
|
|
2479
|
-
maxTurns: safeLimit,
|
|
2480
|
-
sanitizeForRelay,
|
|
2481
|
-
sanitizeContext,
|
|
2482
|
-
payloadSoftLimitBytes,
|
|
2483
|
-
});
|
|
2484
|
-
if (response) {
|
|
2485
|
-
return response;
|
|
2486
|
-
}
|
|
2487
|
-
} catch {
|
|
2488
|
-
// Fall through to a valid empty page: the phone can keep the thread open instead of crashing.
|
|
2928
|
+
const page = await fetchMeasuredAdaptiveTurnsListPage(fetchPage, safeParams, now);
|
|
2929
|
+
const pageResult = unwrapAppServerPayloadResult(page.result);
|
|
2930
|
+
const turnsKey = findTurnsListResultKey(pageResult);
|
|
2931
|
+
if (!turnsKey) {
|
|
2932
|
+
throw new Error("thread/turns/list returned no turns array.");
|
|
2489
2933
|
}
|
|
2490
2934
|
|
|
2491
|
-
|
|
2935
|
+
// If the normal pagination path returns a bad first page, retry once with a small page.
|
|
2936
|
+
// The retry response is intentionally minimal so Swift does not decode stale server metadata.
|
|
2937
|
+
const response = buildCompactedCompleteTurnsListResponse({
|
|
2938
|
+
requestId: request.id,
|
|
2939
|
+
firstResult: pageResult,
|
|
2940
|
+
lastResult: pageResult,
|
|
2941
|
+
turnsKey,
|
|
2942
|
+
turns: pageResult[turnsKey],
|
|
2943
|
+
sanitizeForRelay,
|
|
2944
|
+
sanitizeContext,
|
|
2945
|
+
payloadSoftLimitBytes,
|
|
2946
|
+
});
|
|
2947
|
+
if (response) {
|
|
2948
|
+
return response;
|
|
2949
|
+
}
|
|
2950
|
+
throw new Error("thread/turns/list returned a page that is too large to relay safely.");
|
|
2492
2951
|
}
|
|
2493
2952
|
|
|
2494
2953
|
async function fetchMeasuredAdaptiveTurnsListPage(fetchPage, params, now) {
|
|
@@ -2538,6 +2997,48 @@ function buildSafeTurnsListResponse(requestId, firstResult, lastResult, turnsKey
|
|
|
2538
2997
|
};
|
|
2539
2998
|
}
|
|
2540
2999
|
|
|
3000
|
+
function buildCompactedCompleteTurnsListResponse({
|
|
3001
|
+
requestId,
|
|
3002
|
+
firstResult,
|
|
3003
|
+
lastResult,
|
|
3004
|
+
turnsKey,
|
|
3005
|
+
turns,
|
|
3006
|
+
sanitizeForRelay,
|
|
3007
|
+
sanitizeContext = {},
|
|
3008
|
+
payloadSoftLimitBytes,
|
|
3009
|
+
}) {
|
|
3010
|
+
const response = buildSafeTurnsListResponse(
|
|
3011
|
+
requestId,
|
|
3012
|
+
firstResult,
|
|
3013
|
+
lastResult,
|
|
3014
|
+
turnsKey,
|
|
3015
|
+
turns
|
|
3016
|
+
);
|
|
3017
|
+
if (measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
|
|
3018
|
+
return response;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
for (const maxChars of [
|
|
3022
|
+
RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS,
|
|
3023
|
+
Math.floor(RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS / 4),
|
|
3024
|
+
1_000,
|
|
3025
|
+
0,
|
|
3026
|
+
]) {
|
|
3027
|
+
const compacted = buildSafeTurnsListResponse(
|
|
3028
|
+
requestId,
|
|
3029
|
+
firstResult,
|
|
3030
|
+
lastResult,
|
|
3031
|
+
turnsKey,
|
|
3032
|
+
turns.map((turn) => compactTurnsListTurnForRelay(turn, maxChars))
|
|
3033
|
+
);
|
|
3034
|
+
compacted.result.remodexPageCompactedForRelay = true;
|
|
3035
|
+
if (measureSanitizedTurnsListResponseBytes(compacted, sanitizeForRelay, sanitizeContext) < payloadSoftLimitBytes) {
|
|
3036
|
+
return compacted;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
return null;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
2541
3042
|
// Trims oversized history pages progressively: normal page -> 5 turns -> ... -> 1 turn.
|
|
2542
3043
|
function buildLargestSafeTurnsListResponse({
|
|
2543
3044
|
requestId,
|
|
@@ -2639,19 +3140,47 @@ function compactEmergencySingleTurnForRelay(turn, maxChars, maxItems) {
|
|
|
2639
3140
|
}
|
|
2640
3141
|
|
|
2641
3142
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
2642
|
-
safeTurn.items = items
|
|
3143
|
+
safeTurn.items = selectEmergencyHistoryItemsForRelay(items, maxItems)
|
|
3144
|
+
.map((item) => compactHistoryItemForRelay(item, maxChars));
|
|
2643
3145
|
safeTurn.remodexEmergencySingleTurnForRelay = true;
|
|
2644
3146
|
safeTurn.remodexPageCompactedForRelay = true;
|
|
2645
3147
|
return safeTurn;
|
|
2646
3148
|
}
|
|
2647
3149
|
|
|
3150
|
+
function selectEmergencyHistoryItemsForRelay(items, maxItems) {
|
|
3151
|
+
if (!Array.isArray(items) || items.length <= maxItems) {
|
|
3152
|
+
return Array.isArray(items) ? items : [];
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
const selectedIndices = new Set();
|
|
3156
|
+
const firstUserIndex = items.findIndex((item) => isUserRoleItem(item));
|
|
3157
|
+
if (firstUserIndex >= 0) {
|
|
3158
|
+
selectedIndices.add(firstUserIndex);
|
|
3159
|
+
}
|
|
3160
|
+
for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
|
|
3161
|
+
const type = normalizeHistoryItemToken(items[index]?.type);
|
|
3162
|
+
if (type === "plan" || type === "filechange") {
|
|
3163
|
+
selectedIndices.add(index);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
for (let index = items.length - 1; index >= 0 && selectedIndices.size < maxItems; index -= 1) {
|
|
3167
|
+
selectedIndices.add(index);
|
|
3168
|
+
}
|
|
3169
|
+
return [...selectedIndices]
|
|
3170
|
+
.sort((left, right) => left - right)
|
|
3171
|
+
.map((index) => items[index]);
|
|
3172
|
+
}
|
|
3173
|
+
|
|
2648
3174
|
function buildAdaptiveTurnsListResult(firstResult, lastResult, turnsKey, turns) {
|
|
2649
3175
|
const result = {};
|
|
2650
3176
|
result[turnsKey] = turns;
|
|
2651
3177
|
|
|
2652
3178
|
for (const key of RELAY_TURNS_LIST_PAGINATION_RESULT_KEYS) {
|
|
2653
|
-
|
|
2654
|
-
|
|
3179
|
+
const sourceResult = RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS.has(key)
|
|
3180
|
+
? firstResult
|
|
3181
|
+
: lastResult;
|
|
3182
|
+
if (Object.prototype.hasOwnProperty.call(sourceResult, key)) {
|
|
3183
|
+
result[key] = sourceResult[key];
|
|
2655
3184
|
} else {
|
|
2656
3185
|
delete result[key];
|
|
2657
3186
|
}
|
|
@@ -2698,8 +3227,10 @@ function measureSanitizedTurnsListResponseBytes(response, sanitizeForRelay, requ
|
|
|
2698
3227
|
// Keeps app-server responses in the JSON-RPC shape that the App Store iOS client decodes.
|
|
2699
3228
|
function normalizeRelayBoundJsonRpcMessage(rawMessage, {
|
|
2700
3229
|
pendingRequestMethodsById = null,
|
|
3230
|
+
// Optional pre-parsed envelope shared by the caller; treated as read-only.
|
|
3231
|
+
parsedMessage = null,
|
|
2701
3232
|
} = {}) {
|
|
2702
|
-
const parsed = parseBridgeJSON(rawMessage);
|
|
3233
|
+
const parsed = parsedMessage ?? parseBridgeJSON(rawMessage);
|
|
2703
3234
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2704
3235
|
return null;
|
|
2705
3236
|
}
|
|
@@ -2818,11 +3349,32 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
|
|
|
2818
3349
|
|| normalizeNonEmptyString(thread.id)
|
|
2819
3350
|
|| normalizeNonEmptyString(thread.threadId)
|
|
2820
3351
|
|| normalizeNonEmptyString(thread.thread_id);
|
|
2821
|
-
const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(thread.turns, threadId);
|
|
2822
|
-
const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(thread, threadId);
|
|
2823
|
-
const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(sanitizedTurns, threadId);
|
|
2824
3352
|
|
|
2825
|
-
|
|
3353
|
+
// Oversized histories get their turn window trimmed before the per-turn sanitize
|
|
3354
|
+
// and augment passes so full-history work is not spent on turns the payload
|
|
3355
|
+
// budget discards anyway. The byte-budget trim below still enforces the cap.
|
|
3356
|
+
const didPreTrimTurnWindow = Buffer.byteLength(rawMessage, "utf8") > RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES
|
|
3357
|
+
&& thread.turns.length > RELAY_HISTORY_RECENT_TURN_TARGET;
|
|
3358
|
+
const workingTurns = didPreTrimTurnWindow
|
|
3359
|
+
? thread.turns.slice(-RELAY_HISTORY_RECENT_TURN_TARGET)
|
|
3360
|
+
: thread.turns;
|
|
3361
|
+
const workingThread = didPreTrimTurnWindow ? { ...thread, turns: workingTurns } : thread;
|
|
3362
|
+
const trimOptions = didPreTrimTurnWindow
|
|
3363
|
+
? {
|
|
3364
|
+
preOmittedTurnCount: thread.turns.length - workingTurns.length,
|
|
3365
|
+
compactionIdSource: thread.turns[0],
|
|
3366
|
+
}
|
|
3367
|
+
: {};
|
|
3368
|
+
|
|
3369
|
+
const { turns: sanitizedTurns, didSanitize } = sanitizeRelayHistoryTurns(workingTurns, threadId);
|
|
3370
|
+
const { thread: threadWithJsonlMetadata, didAugment: didAugmentThreadMetadata } = augmentRelayThreadWithJsonlMetadata(workingThread, threadId);
|
|
3371
|
+
const { turns: augmentedTurns, didAugment } = augmentRelayHistoryTurnsWithJsonlArtifacts(
|
|
3372
|
+
sanitizedTurns,
|
|
3373
|
+
threadId,
|
|
3374
|
+
{ includeHistoryItems: true }
|
|
3375
|
+
);
|
|
3376
|
+
|
|
3377
|
+
if (!didSanitize && !didAugment && !didAugmentThreadMetadata && !didPreTrimTurnWindow) {
|
|
2826
3378
|
const trimmedPayload = trimThreadPayloadForRelay(parsed, thread);
|
|
2827
3379
|
return trimmedPayload == null ? rawMessage : trimmedPayload;
|
|
2828
3380
|
}
|
|
@@ -2838,7 +3390,7 @@ function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod, requestC
|
|
|
2838
3390
|
},
|
|
2839
3391
|
});
|
|
2840
3392
|
|
|
2841
|
-
return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null) ?? sanitizedPayload;
|
|
3393
|
+
return trimThreadPayloadForRelay(parseBridgeJSON(sanitizedPayload), null, trimOptions) ?? sanitizedPayload;
|
|
2842
3394
|
}
|
|
2843
3395
|
|
|
2844
3396
|
function sanitizeThreadTurnsListForRelay(rawMessage, requestContext = {}) {
|
|
@@ -2906,27 +3458,104 @@ function readJsonlThreadCwd(threadId) {
|
|
|
2906
3458
|
return "";
|
|
2907
3459
|
}
|
|
2908
3460
|
|
|
3461
|
+
const sessionsRoot = resolveSessionsRoot();
|
|
3462
|
+
const cacheKey = buildJsonlThreadCacheKey(sessionsRoot, normalizedThreadId);
|
|
3463
|
+
|
|
2909
3464
|
try {
|
|
2910
|
-
const rolloutPath = findRecentRolloutFileForContextRead(
|
|
3465
|
+
const rolloutPath = findRecentRolloutFileForContextRead(sessionsRoot, { threadId: normalizedThreadId });
|
|
2911
3466
|
if (!rolloutPath) {
|
|
2912
3467
|
return "";
|
|
2913
3468
|
}
|
|
2914
3469
|
|
|
2915
|
-
const
|
|
2916
|
-
|
|
2917
|
-
|
|
3470
|
+
const cached = readCachedJsonlThreadCwd(cacheKey, rolloutPath);
|
|
3471
|
+
if (cached) {
|
|
3472
|
+
return cached.cwd;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
return readAndCacheJsonlThreadCwd(cacheKey, rolloutPath);
|
|
2918
3476
|
} catch {
|
|
2919
3477
|
return "";
|
|
2920
3478
|
}
|
|
2921
3479
|
}
|
|
2922
3480
|
|
|
2923
|
-
function
|
|
3481
|
+
function readCachedJsonlThreadCwd(cacheKey, rolloutPath) {
|
|
3482
|
+
const cached = jsonlThreadCwdCacheByThread.get(cacheKey);
|
|
3483
|
+
if (!cached || cached.rolloutPath !== rolloutPath) {
|
|
3484
|
+
return null;
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
const stat = statJsonlRollout(rolloutPath);
|
|
3488
|
+
if (!stat) {
|
|
3489
|
+
jsonlThreadCwdCacheByThread.delete(cacheKey);
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
|
|
3494
|
+
return null;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
const ttl = cached.cwd ? RELAY_JSONL_THREAD_CWD_CACHE_TTL_MS : RELAY_JSONL_THREAD_EMPTY_CWD_CACHE_TTL_MS;
|
|
3498
|
+
if (Date.now() - cached.checkedAt > ttl) {
|
|
3499
|
+
return null;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
return { cwd: cached.cwd };
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
function readAndCacheJsonlThreadCwd(cacheKey, rolloutPath, stat = null) {
|
|
3506
|
+
const rolloutStat = stat || statJsonlRollout(rolloutPath);
|
|
3507
|
+
if (!rolloutStat) {
|
|
3508
|
+
jsonlThreadCwdCacheByThread.delete(cacheKey);
|
|
3509
|
+
return "";
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
let cwd = "";
|
|
3513
|
+
try {
|
|
3514
|
+
const metadata = readSessionJsonlMetadataFromFile(rolloutPath);
|
|
3515
|
+
const parsedCwd = normalizeNonEmptyString(metadata?.cwd);
|
|
3516
|
+
cwd = parsedCwd && path.isAbsolute(parsedCwd) ? parsedCwd : "";
|
|
3517
|
+
} catch {
|
|
3518
|
+
cwd = "";
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
rememberJsonlThreadCwdCache(cacheKey, {
|
|
3522
|
+
rolloutPath,
|
|
3523
|
+
cwd,
|
|
3524
|
+
mtimeMs: rolloutStat.mtimeMs,
|
|
3525
|
+
size: rolloutStat.size,
|
|
3526
|
+
checkedAt: Date.now(),
|
|
3527
|
+
});
|
|
3528
|
+
return cwd;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function rememberJsonlThreadCwdCache(cacheKey, entry) {
|
|
3532
|
+
jsonlThreadCwdCacheByThread.set(cacheKey, entry);
|
|
3533
|
+
while (jsonlThreadCwdCacheByThread.size > JSONL_ROLLOUT_PATH_CACHE_MAX_SIZE) {
|
|
3534
|
+
const oldestKey = jsonlThreadCwdCacheByThread.keys().next().value;
|
|
3535
|
+
if (oldestKey == null) {
|
|
3536
|
+
break;
|
|
3537
|
+
}
|
|
3538
|
+
jsonlThreadCwdCacheByThread.delete(oldestKey);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "", {
|
|
3543
|
+
includeHistoryItems = false,
|
|
3544
|
+
} = {}) {
|
|
2924
3545
|
const normalizedThreadId = normalizeNonEmptyString(threadId);
|
|
2925
3546
|
if (!normalizedThreadId || !Array.isArray(turns) || turns.length === 0) {
|
|
2926
3547
|
return { turns, didAugment: false };
|
|
2927
3548
|
}
|
|
2928
3549
|
|
|
2929
|
-
const
|
|
3550
|
+
const requestedTurnIds = new Set(turns.map((turn) => (
|
|
3551
|
+
normalizeNonEmptyString(turn?.id)
|
|
3552
|
+
|| normalizeNonEmptyString(turn?.turnId)
|
|
3553
|
+
|| normalizeNonEmptyString(turn?.turn_id)
|
|
3554
|
+
)).filter(Boolean));
|
|
3555
|
+
const jsonlArtifactsByTurnId = readJsonlArtifactItemsByTurnId(
|
|
3556
|
+
normalizedThreadId,
|
|
3557
|
+
requestedTurnIds
|
|
3558
|
+
);
|
|
2930
3559
|
if (jsonlArtifactsByTurnId.size === 0) {
|
|
2931
3560
|
return { turns, didAugment: false };
|
|
2932
3561
|
}
|
|
@@ -2942,22 +3571,17 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
|
|
|
2942
3571
|
}
|
|
2943
3572
|
|
|
2944
3573
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
3574
|
+
const merged = mergeRelayHistoryItemsWithJsonlItems(
|
|
3575
|
+
items,
|
|
3576
|
+
artifacts.timelineItems,
|
|
3577
|
+
normalizedThreadId,
|
|
3578
|
+
{
|
|
3579
|
+
includeJsonlItem: includeHistoryItems
|
|
3580
|
+
? () => true
|
|
3581
|
+
: isJsonlHistoryArtifactItem,
|
|
2953
3582
|
}
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
}
|
|
2957
|
-
if (artifacts.progressPlanItem && !hasEquivalentProgressPlanItem(nextItems, artifacts.progressPlanItem)) {
|
|
2958
|
-
nextItems = nextItems === items ? [...items] : nextItems;
|
|
2959
|
-
nextItems.push(artifacts.progressPlanItem);
|
|
2960
|
-
}
|
|
3583
|
+
);
|
|
3584
|
+
const nextItems = merged.items;
|
|
2961
3585
|
|
|
2962
3586
|
if (nextItems === items) {
|
|
2963
3587
|
return turn;
|
|
@@ -2973,7 +3597,7 @@ function augmentRelayHistoryTurnsWithJsonlArtifacts(turns, threadId = "") {
|
|
|
2973
3597
|
return { turns: didAugment ? augmentedTurns : turns, didAugment };
|
|
2974
3598
|
}
|
|
2975
3599
|
|
|
2976
|
-
function readJsonlArtifactItemsByTurnId(threadId) {
|
|
3600
|
+
function readJsonlArtifactItemsByTurnId(threadId, requestedTurnIds = new Set()) {
|
|
2977
3601
|
const emptyArtifactsByTurnId = new Map();
|
|
2978
3602
|
const normalizedThreadId = normalizeNonEmptyString(threadId);
|
|
2979
3603
|
if (!normalizedThreadId) {
|
|
@@ -2982,7 +3606,11 @@ function readJsonlArtifactItemsByTurnId(threadId) {
|
|
|
2982
3606
|
|
|
2983
3607
|
const sessionsRoot = resolveSessionsRoot();
|
|
2984
3608
|
const cacheKey = buildJsonlArtifactItemsCacheKey(sessionsRoot, normalizedThreadId);
|
|
2985
|
-
const cachedArtifacts = readCachedJsonlArtifactItems(
|
|
3609
|
+
const cachedArtifacts = readCachedJsonlArtifactItems(
|
|
3610
|
+
cacheKey,
|
|
3611
|
+
normalizedThreadId,
|
|
3612
|
+
requestedTurnIds
|
|
3613
|
+
);
|
|
2986
3614
|
if (cachedArtifacts) {
|
|
2987
3615
|
return cachedArtifacts;
|
|
2988
3616
|
}
|
|
@@ -2994,7 +3622,13 @@ function readJsonlArtifactItemsByTurnId(threadId) {
|
|
|
2994
3622
|
return emptyArtifactsByTurnId;
|
|
2995
3623
|
}
|
|
2996
3624
|
|
|
2997
|
-
return readAndCacheJsonlArtifactItems(
|
|
3625
|
+
return readAndCacheJsonlArtifactItems(
|
|
3626
|
+
cacheKey,
|
|
3627
|
+
rolloutPath,
|
|
3628
|
+
normalizedThreadId,
|
|
3629
|
+
null,
|
|
3630
|
+
requestedTurnIds
|
|
3631
|
+
);
|
|
2998
3632
|
} catch (error) {
|
|
2999
3633
|
jsonlArtifactItemsCacheByThread.delete(cacheKey);
|
|
3000
3634
|
console.warn(`[remodex] history jsonl artifact augmentation failed for ${normalizedThreadId}: ${error.message}`);
|
|
@@ -3004,16 +3638,20 @@ function readJsonlArtifactItemsByTurnId(threadId) {
|
|
|
3004
3638
|
}
|
|
3005
3639
|
|
|
3006
3640
|
function buildJsonlArtifactItemsCacheKey(sessionsRoot, threadId) {
|
|
3641
|
+
return buildJsonlThreadCacheKey(sessionsRoot, threadId);
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function buildJsonlThreadCacheKey(sessionsRoot, threadId) {
|
|
3007
3645
|
return `${sessionsRoot}\0${threadId}`;
|
|
3008
3646
|
}
|
|
3009
3647
|
|
|
3010
|
-
function readCachedJsonlArtifactItems(cacheKey, threadId) {
|
|
3648
|
+
function readCachedJsonlArtifactItems(cacheKey, threadId, requestedTurnIds = new Set()) {
|
|
3011
3649
|
const cached = jsonlArtifactItemsCacheByThread.get(cacheKey);
|
|
3012
3650
|
if (!cached) {
|
|
3013
3651
|
return null;
|
|
3014
3652
|
}
|
|
3015
3653
|
|
|
3016
|
-
const stat =
|
|
3654
|
+
const stat = statJsonlRollout(cached.rolloutPath);
|
|
3017
3655
|
if (!stat) {
|
|
3018
3656
|
jsonlArtifactItemsCacheByThread.delete(cacheKey);
|
|
3019
3657
|
return null;
|
|
@@ -3021,7 +3659,13 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
|
|
|
3021
3659
|
|
|
3022
3660
|
if (stat.mtimeMs !== cached.mtimeMs || stat.size !== cached.size) {
|
|
3023
3661
|
try {
|
|
3024
|
-
return readAndCacheJsonlArtifactItems(
|
|
3662
|
+
return readAndCacheJsonlArtifactItems(
|
|
3663
|
+
cacheKey,
|
|
3664
|
+
cached.rolloutPath,
|
|
3665
|
+
threadId,
|
|
3666
|
+
stat,
|
|
3667
|
+
requestedTurnIds
|
|
3668
|
+
);
|
|
3025
3669
|
} catch (error) {
|
|
3026
3670
|
jsonlArtifactItemsCacheByThread.delete(cacheKey);
|
|
3027
3671
|
console.warn(`[remodex] history jsonl artifact cache refresh failed for ${threadId}: ${error.message}`);
|
|
@@ -3030,6 +3674,11 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
|
|
|
3030
3674
|
}
|
|
3031
3675
|
|
|
3032
3676
|
const now = Date.now();
|
|
3677
|
+
const coversRequestedTurns = cached.coversEntireRollout
|
|
3678
|
+
|| [...requestedTurnIds].every((turnId) => cached.coveredTurnIds?.has(turnId));
|
|
3679
|
+
if (!coversRequestedTurns) {
|
|
3680
|
+
return null;
|
|
3681
|
+
}
|
|
3033
3682
|
if (now - cached.checkedAt <= RELAY_JSONL_ARTIFACT_CACHE_TTL_MS) {
|
|
3034
3683
|
return cached.artifactsByTurnId;
|
|
3035
3684
|
}
|
|
@@ -3038,11 +3687,37 @@ function readCachedJsonlArtifactItems(cacheKey, threadId) {
|
|
|
3038
3687
|
return null;
|
|
3039
3688
|
}
|
|
3040
3689
|
|
|
3041
|
-
function readAndCacheJsonlArtifactItems(
|
|
3690
|
+
function readAndCacheJsonlArtifactItems(
|
|
3691
|
+
cacheKey,
|
|
3692
|
+
rolloutPath,
|
|
3693
|
+
threadId,
|
|
3694
|
+
stat = null,
|
|
3695
|
+
requestedTurnIds = new Set()
|
|
3696
|
+
) {
|
|
3042
3697
|
const rolloutStat = stat || fs.statSync(rolloutPath);
|
|
3043
3698
|
const artifactsByTurnId = new Map();
|
|
3699
|
+
let coveredTurnIds = new Set();
|
|
3700
|
+
let coversEntireRollout = false;
|
|
3044
3701
|
try {
|
|
3045
|
-
const
|
|
3702
|
+
const recent = readRecentSessionJsonlTurns(rolloutPath, {
|
|
3703
|
+
threadId,
|
|
3704
|
+
limit: RELAY_TURNS_LIST_SAFE_RETRY_LIMIT,
|
|
3705
|
+
});
|
|
3706
|
+
let turns = recent?.turns || [];
|
|
3707
|
+
coversEntireRollout = recent ? !recent.hasOlderTurns : false;
|
|
3708
|
+
coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
|
|
3709
|
+
const missesRequestedTurn = [...requestedTurnIds].some((turnId) => !coveredTurnIds.has(turnId));
|
|
3710
|
+
|
|
3711
|
+
// Preserve the old exact artifact behavior for files V8 can safely decode,
|
|
3712
|
+
// but only pay that cost when an older cursor page actually asks for a turn
|
|
3713
|
+
// outside the fast tail. Multi-gigabyte files never enter this path.
|
|
3714
|
+
if (!coversEntireRollout
|
|
3715
|
+
&& missesRequestedTurn
|
|
3716
|
+
&& rolloutStat.size <= RELAY_JSONL_FULL_ARTIFACT_FALLBACK_MAX_BYTES) {
|
|
3717
|
+
turns = parseSessionJsonlTurns(fs.readFileSync(rolloutPath, "utf8"), { threadId });
|
|
3718
|
+
coversEntireRollout = true;
|
|
3719
|
+
coveredTurnIds = new Set(turns.map((turn) => normalizeNonEmptyString(turn?.id)).filter(Boolean));
|
|
3720
|
+
}
|
|
3046
3721
|
for (const turn of turns) {
|
|
3047
3722
|
const turnId = normalizeNonEmptyString(turn?.id);
|
|
3048
3723
|
const turnItems = Array.isArray(turn?.items) ? turn.items : [];
|
|
@@ -3050,47 +3725,9 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
|
|
|
3050
3725
|
continue;
|
|
3051
3726
|
}
|
|
3052
3727
|
|
|
3053
|
-
const
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
&& item?.remodexJsonlProgressPlan === true
|
|
3057
|
-
));
|
|
3058
|
-
const artifacts = {
|
|
3059
|
-
fileChangeItem: null,
|
|
3060
|
-
imageViewItems: [],
|
|
3061
|
-
progressPlanItem: null,
|
|
3062
|
-
};
|
|
3063
|
-
|
|
3064
|
-
const changes = [];
|
|
3065
|
-
for (const item of fileChanges) {
|
|
3066
|
-
if (Array.isArray(item.changes)) {
|
|
3067
|
-
changes.push(...item.changes);
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
if (changes.length > 0) {
|
|
3071
|
-
artifacts.fileChangeItem = {
|
|
3072
|
-
id: `remodex-jsonl-file-change-${turnId}`,
|
|
3073
|
-
type: "fileChange",
|
|
3074
|
-
status: "completed",
|
|
3075
|
-
changes,
|
|
3076
|
-
remodexJsonlFileChangeAggregate: true,
|
|
3077
|
-
};
|
|
3078
|
-
}
|
|
3079
|
-
if (progressPlan) {
|
|
3080
|
-
artifacts.progressPlanItem = {
|
|
3081
|
-
...progressPlan,
|
|
3082
|
-
id: normalizeNonEmptyString(progressPlan.id) || `remodex-jsonl-progress-plan-${turnId}`,
|
|
3083
|
-
};
|
|
3084
|
-
}
|
|
3085
|
-
artifacts.imageViewItems = turnItems
|
|
3086
|
-
.filter((item) => normalizeHistoryItemToken(item?.type) === "imageview")
|
|
3087
|
-
.map((item, index) => ({
|
|
3088
|
-
...item,
|
|
3089
|
-
id: normalizeNonEmptyString(item.id) || `remodex-jsonl-image-view-${turnId}-${index + 1}`,
|
|
3090
|
-
}));
|
|
3091
|
-
|
|
3092
|
-
if (artifacts.fileChangeItem || artifacts.progressPlanItem || artifacts.imageViewItems.length > 0) {
|
|
3093
|
-
artifactsByTurnId.set(turnId, artifacts);
|
|
3728
|
+
const timelineItems = buildOrderedJsonlTimelineItems(turnItems, turnId);
|
|
3729
|
+
if (timelineItems.length > 0) {
|
|
3730
|
+
artifactsByTurnId.set(turnId, { timelineItems });
|
|
3094
3731
|
}
|
|
3095
3732
|
}
|
|
3096
3733
|
} catch (error) {
|
|
@@ -3104,11 +3741,13 @@ function readAndCacheJsonlArtifactItems(cacheKey, rolloutPath, threadId, stat =
|
|
|
3104
3741
|
size: rolloutStat.size,
|
|
3105
3742
|
checkedAt: Date.now(),
|
|
3106
3743
|
artifactsByTurnId,
|
|
3744
|
+
coveredTurnIds,
|
|
3745
|
+
coversEntireRollout,
|
|
3107
3746
|
});
|
|
3108
3747
|
return artifactsByTurnId;
|
|
3109
3748
|
}
|
|
3110
3749
|
|
|
3111
|
-
function
|
|
3750
|
+
function statJsonlRollout(rolloutPath) {
|
|
3112
3751
|
try {
|
|
3113
3752
|
return fs.statSync(rolloutPath);
|
|
3114
3753
|
} catch {
|
|
@@ -3127,57 +3766,406 @@ function rememberJsonlArtifactItemsCache(cacheKey, entry) {
|
|
|
3127
3766
|
}
|
|
3128
3767
|
}
|
|
3129
3768
|
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3769
|
+
// Keeps JSONL-only rows in rollout order while treating app-server rows as the
|
|
3770
|
+
// authoritative spine. Matching anchors let us place missing rows without ever
|
|
3771
|
+
// moving server-only findings, messages, or richer tool records to the tail.
|
|
3772
|
+
function mergeRelayHistoryItemsWithJsonlItems(existingItems, jsonlItems, threadId = "", {
|
|
3773
|
+
includeJsonlItem = () => true,
|
|
3774
|
+
} = {}) {
|
|
3775
|
+
if (!Array.isArray(existingItems) || !Array.isArray(jsonlItems) || jsonlItems.length === 0) {
|
|
3776
|
+
return { items: existingItems, didMerge: false };
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
const sanitizedJsonlItems = jsonlItems
|
|
3780
|
+
.map((item) => sanitizeJsonlHistoryItemForRelayMerge(item, threadId))
|
|
3781
|
+
.filter(Boolean);
|
|
3782
|
+
if (sanitizedJsonlItems.length === 0) {
|
|
3783
|
+
return { items: existingItems, didMerge: false };
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
if (existingItems.length === 0) {
|
|
3787
|
+
const insertedItems = sanitizedJsonlItems.filter(includeJsonlItem);
|
|
3788
|
+
return insertedItems.length > 0
|
|
3789
|
+
? { items: insertedItems, didMerge: true }
|
|
3790
|
+
: { items: existingItems, didMerge: false };
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
const usedExistingIndices = new Set();
|
|
3794
|
+
const resolvedExistingItems = existingItems.slice();
|
|
3795
|
+
const insertionsBefore = new Map();
|
|
3796
|
+
const insertionsAfter = new Map();
|
|
3797
|
+
let pendingItems = [];
|
|
3798
|
+
let previousMatchedIndex = null;
|
|
3799
|
+
let matchedAnchorCount = 0;
|
|
3800
|
+
let didReplaceMatchedItem = false;
|
|
3801
|
+
|
|
3802
|
+
// The rollout's turn+text alias is the only identity shared across the
|
|
3803
|
+
// live-owner (item_N) and rollout (msg_...) views of one assistant reply.
|
|
3804
|
+
// Carrying it onto the matched server row lets the phone join this item
|
|
3805
|
+
// with its other-source representations instead of duplicating it.
|
|
3806
|
+
const adoptJsonlSourceAlias = (index, jsonlItem) => {
|
|
3807
|
+
const sourceKey = normalizeNonEmptyString(jsonlItem?.remodexSourceItemKey);
|
|
3808
|
+
const existing = resolvedExistingItems[index];
|
|
3809
|
+
if (!sourceKey || !existing || typeof existing !== "object"
|
|
3810
|
+
|| normalizeNonEmptyString(existing.remodexSourceItemKey)) {
|
|
3811
|
+
return;
|
|
3136
3812
|
}
|
|
3137
|
-
|
|
3138
|
-
|
|
3813
|
+
resolvedExistingItems[index] = { ...existing, remodexSourceItemKey: sourceKey };
|
|
3814
|
+
didReplaceMatchedItem = true;
|
|
3815
|
+
};
|
|
3816
|
+
|
|
3817
|
+
const placePendingItems = () => {
|
|
3818
|
+
if (pendingItems.length === 0) {
|
|
3819
|
+
return;
|
|
3139
3820
|
}
|
|
3140
|
-
if (
|
|
3141
|
-
|
|
3821
|
+
if (previousMatchedIndex == null) {
|
|
3822
|
+
insertionsBefore.set(0, pendingItems);
|
|
3823
|
+
} else {
|
|
3824
|
+
const existing = insertionsAfter.get(previousMatchedIndex) || [];
|
|
3825
|
+
insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
|
|
3142
3826
|
}
|
|
3827
|
+
pendingItems = [];
|
|
3828
|
+
};
|
|
3143
3829
|
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3830
|
+
for (const jsonlItem of sanitizedJsonlItems) {
|
|
3831
|
+
const unusedMatch = (candidate, index) => !usedExistingIndices.has(index);
|
|
3832
|
+
const eligibleMatch = (candidate, index) => (
|
|
3833
|
+
(previousMatchedIndex == null || index > previousMatchedIndex)
|
|
3834
|
+
&& unusedMatch(candidate, index)
|
|
3835
|
+
);
|
|
3836
|
+
let existingIndex = findRelayHistoryExactMatchIndex(existingItems, jsonlItem, eligibleMatch);
|
|
3837
|
+
if (existingIndex === -1) {
|
|
3838
|
+
// Exact identity remains authoritative even when server and rollout order
|
|
3839
|
+
// disagree. Consume that occurrence before considering a later semantic
|
|
3840
|
+
// lookalike, otherwise repeated rows can bind to the wrong server item.
|
|
3841
|
+
const representedExactIndex = findRelayHistoryExactMatchIndex(
|
|
3842
|
+
existingItems,
|
|
3843
|
+
jsonlItem,
|
|
3844
|
+
unusedMatch
|
|
3845
|
+
);
|
|
3846
|
+
if (representedExactIndex !== -1) {
|
|
3847
|
+
usedExistingIndices.add(representedExactIndex);
|
|
3848
|
+
if (isProgressPlanItem(jsonlItem)) {
|
|
3849
|
+
resolvedExistingItems[representedExactIndex] = resolvedProgressPlanHistoryItem(
|
|
3850
|
+
existingItems[representedExactIndex],
|
|
3851
|
+
jsonlItem
|
|
3852
|
+
);
|
|
3853
|
+
didReplaceMatchedItem = true;
|
|
3854
|
+
}
|
|
3855
|
+
adoptJsonlSourceAlias(representedExactIndex, jsonlItem);
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
existingIndex = findRelayHistorySemanticMatchIndex(existingItems, jsonlItem, eligibleMatch);
|
|
3859
|
+
}
|
|
3860
|
+
if (existingIndex === -1) {
|
|
3861
|
+
// The row can already exist before the monotonic placement frontier when
|
|
3862
|
+
// server and rollout order disagree. Consume each represented occurrence
|
|
3863
|
+
// once; an extra identical JSONL occurrence must remain visible instead
|
|
3864
|
+
// of repeatedly matching the same server row and disappearing.
|
|
3865
|
+
const representedIndex = findRelayHistorySemanticMatchIndex(
|
|
3866
|
+
existingItems,
|
|
3867
|
+
jsonlItem,
|
|
3868
|
+
unusedMatch
|
|
3869
|
+
);
|
|
3870
|
+
if (representedIndex !== -1) {
|
|
3871
|
+
usedExistingIndices.add(representedIndex);
|
|
3872
|
+
if (isProgressPlanItem(jsonlItem)) {
|
|
3873
|
+
resolvedExistingItems[representedIndex] = resolvedProgressPlanHistoryItem(
|
|
3874
|
+
existingItems[representedIndex],
|
|
3875
|
+
jsonlItem
|
|
3876
|
+
);
|
|
3877
|
+
didReplaceMatchedItem = true;
|
|
3878
|
+
}
|
|
3879
|
+
adoptJsonlSourceAlias(representedIndex, jsonlItem);
|
|
3880
|
+
continue;
|
|
3151
3881
|
}
|
|
3882
|
+
if (includeJsonlItem(jsonlItem)) {
|
|
3883
|
+
pendingItems.push(jsonlItem);
|
|
3884
|
+
}
|
|
3885
|
+
continue;
|
|
3152
3886
|
}
|
|
3153
|
-
|
|
3154
|
-
|
|
3887
|
+
|
|
3888
|
+
placePendingItems();
|
|
3889
|
+
usedExistingIndices.add(existingIndex);
|
|
3890
|
+
if (isProgressPlanItem(jsonlItem)) {
|
|
3891
|
+
resolvedExistingItems[existingIndex] = resolvedProgressPlanHistoryItem(
|
|
3892
|
+
existingItems[existingIndex],
|
|
3893
|
+
jsonlItem
|
|
3894
|
+
);
|
|
3895
|
+
didReplaceMatchedItem = true;
|
|
3896
|
+
}
|
|
3897
|
+
adoptJsonlSourceAlias(existingIndex, jsonlItem);
|
|
3898
|
+
previousMatchedIndex = existingIndex;
|
|
3899
|
+
matchedAnchorCount += 1;
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
if (matchedAnchorCount === 0) {
|
|
3903
|
+
const unanchoredArtifacts = sanitizedJsonlItems.filter((item) => (
|
|
3904
|
+
includeJsonlItem(item) && isJsonlHistoryArtifactItem(item)
|
|
3905
|
+
));
|
|
3906
|
+
if (unanchoredArtifacts.length === 0) {
|
|
3907
|
+
return { items: existingItems, didMerge: false };
|
|
3908
|
+
}
|
|
3909
|
+
const firstAssistantIndex = existingItems.findIndex(isRelayAssistantHistoryItem);
|
|
3910
|
+
const insertionIndex = firstAssistantIndex === -1 ? existingItems.length : firstAssistantIndex;
|
|
3911
|
+
return {
|
|
3912
|
+
items: existingItems.slice(0, insertionIndex)
|
|
3913
|
+
.concat(unanchoredArtifacts, existingItems.slice(insertionIndex)),
|
|
3914
|
+
didMerge: true,
|
|
3915
|
+
};
|
|
3916
|
+
}
|
|
3917
|
+
if (pendingItems.length > 0 && previousMatchedIndex != null) {
|
|
3918
|
+
const existing = insertionsAfter.get(previousMatchedIndex) || [];
|
|
3919
|
+
insertionsAfter.set(previousMatchedIndex, existing.concat(pendingItems));
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
if (insertionsBefore.size === 0 && insertionsAfter.size === 0 && !didReplaceMatchedItem) {
|
|
3923
|
+
return { items: existingItems, didMerge: false };
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
const mergedItems = [];
|
|
3927
|
+
for (const [index, item] of resolvedExistingItems.entries()) {
|
|
3928
|
+
mergedItems.push(...(insertionsBefore.get(index) || []));
|
|
3929
|
+
mergedItems.push(item);
|
|
3930
|
+
mergedItems.push(...(insertionsAfter.get(index) || []));
|
|
3931
|
+
}
|
|
3932
|
+
return { items: mergedItems, didMerge: true };
|
|
3155
3933
|
}
|
|
3156
3934
|
|
|
3157
|
-
function
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3935
|
+
function buildOrderedJsonlTimelineItems(turnItems, turnId) {
|
|
3936
|
+
if (!Array.isArray(turnItems) || turnItems.length === 0) {
|
|
3937
|
+
return [];
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
let latestProgressPlanIndex = -1;
|
|
3941
|
+
for (const [index, item] of turnItems.entries()) {
|
|
3942
|
+
if (isProgressPlanItem(item)) {
|
|
3943
|
+
latestProgressPlanIndex = index;
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
let imageViewIndex = 0;
|
|
3948
|
+
return turnItems.flatMap((item, index) => {
|
|
3949
|
+
if (!shouldIncludeJsonlTimelineItem(item)) {
|
|
3950
|
+
return [];
|
|
3951
|
+
}
|
|
3952
|
+
if (isProgressPlanItem(item) && index !== latestProgressPlanIndex) {
|
|
3953
|
+
return [];
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
const itemType = normalizeHistoryItemToken(item?.type);
|
|
3957
|
+
if (isProgressPlanItem(item)) {
|
|
3958
|
+
return [{
|
|
3959
|
+
...item,
|
|
3960
|
+
id: normalizeNonEmptyString(item?.id) || `remodex-jsonl-progress-plan-${turnId}`,
|
|
3961
|
+
remodexProgressPlan: true,
|
|
3962
|
+
remodexJsonlProgressPlan: true,
|
|
3963
|
+
}];
|
|
3162
3964
|
}
|
|
3163
|
-
|
|
3164
|
-
|
|
3965
|
+
if (itemType === "imageview") {
|
|
3966
|
+
imageViewIndex += 1;
|
|
3967
|
+
return [{
|
|
3968
|
+
...item,
|
|
3969
|
+
id: normalizeNonEmptyString(item?.id)
|
|
3970
|
+
|| `remodex-jsonl-image-view-${turnId}-${imageViewIndex}`,
|
|
3971
|
+
}];
|
|
3972
|
+
}
|
|
3973
|
+
return [item];
|
|
3165
3974
|
});
|
|
3166
3975
|
}
|
|
3167
3976
|
|
|
3168
|
-
function
|
|
3169
|
-
const
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3977
|
+
function shouldIncludeJsonlTimelineItem(item) {
|
|
3978
|
+
const itemType = normalizeHistoryItemToken(item?.type);
|
|
3979
|
+
return Boolean(itemType)
|
|
3980
|
+
&& itemType !== "toolcalloutput"
|
|
3981
|
+
&& itemType !== "functioncalloutput"
|
|
3982
|
+
&& itemType !== "customtoolcalloutput";
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function isJsonlHistoryArtifactItem(item) {
|
|
3986
|
+
const itemType = normalizeHistoryItemToken(item?.type);
|
|
3987
|
+
return itemType === "filechange"
|
|
3988
|
+
|| itemType === "imageview"
|
|
3989
|
+
|| isProgressPlanItem(item);
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
function isProgressPlanItem(item) {
|
|
3993
|
+
const itemType = normalizeHistoryItemToken(item?.type);
|
|
3994
|
+
return (itemType === "plan" || itemType === "todolist")
|
|
3995
|
+
&& (item?.remodexJsonlProgressPlan === true || item?.remodexProgressPlan === true);
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
function resolvedProgressPlanHistoryItem(existingItem, jsonlItem) {
|
|
3999
|
+
return {
|
|
4000
|
+
...existingItem,
|
|
4001
|
+
text: jsonlItem.text,
|
|
4002
|
+
explanation: jsonlItem.explanation,
|
|
4003
|
+
plan: jsonlItem.plan,
|
|
4004
|
+
remodexProgressPlan: true,
|
|
4005
|
+
remodexJsonlProgressPlan: true,
|
|
4006
|
+
};
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
function findRelayHistoryExactMatchIndex(items, incomingItem, predicate = () => true) {
|
|
4010
|
+
return items.findIndex((candidate, index) => (
|
|
4011
|
+
predicate(candidate, index) && relayHistoryItemsHaveExactIdentity(candidate, incomingItem)
|
|
4012
|
+
));
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
function findRelayHistorySemanticMatchIndex(items, incomingItem, predicate = () => true) {
|
|
4016
|
+
return items.findIndex((candidate, index) => (
|
|
4017
|
+
predicate(candidate, index) && areEquivalentRelayHistoryItems(candidate, incomingItem)
|
|
4018
|
+
));
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
function relayHistoryItemsHaveExactIdentity(first, second) {
|
|
4022
|
+
const firstIdentity = relayHistoryItemIdentity(first);
|
|
4023
|
+
const secondIdentity = relayHistoryItemIdentity(second);
|
|
4024
|
+
if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
|
|
4025
|
+
return true;
|
|
4026
|
+
}
|
|
4027
|
+
const firstCallId = relayHistoryItemCallId(first);
|
|
4028
|
+
const secondCallId = relayHistoryItemCallId(second);
|
|
4029
|
+
return Boolean(firstCallId && secondCallId && firstCallId === secondCallId);
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
function isRelayAssistantHistoryItem(item) {
|
|
4033
|
+
const role = normalizeNonEmptyString(item?.role).toLowerCase();
|
|
4034
|
+
const itemType = normalizeHistoryItemToken(item?.type);
|
|
4035
|
+
return role === "assistant"
|
|
4036
|
+
|| itemType === "assistantmessage"
|
|
4037
|
+
|| itemType === "agentmessage"
|
|
4038
|
+
|| (itemType === "message" && role !== "user");
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
function sanitizeJsonlHistoryItemForRelayMerge(item, threadId) {
|
|
4042
|
+
const sanitizedTurn = sanitizeRelayHistoryTurn({ items: [item] }, threadId);
|
|
4043
|
+
return sanitizedTurn?.items?.[0] || item;
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
function areEquivalentRelayHistoryItems(first, second) {
|
|
4047
|
+
const firstIdentity = relayHistoryItemIdentity(first);
|
|
4048
|
+
const secondIdentity = relayHistoryItemIdentity(second);
|
|
4049
|
+
if (firstIdentity && secondIdentity && firstIdentity === secondIdentity) {
|
|
4050
|
+
return true;
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
const firstCallId = relayHistoryItemCallId(first);
|
|
4054
|
+
const secondCallId = relayHistoryItemCallId(second);
|
|
4055
|
+
if (firstCallId && secondCallId && firstCallId === secondCallId) {
|
|
4056
|
+
return true;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
if (isProgressPlanItem(first) && isProgressPlanItem(second)) {
|
|
4060
|
+
return true;
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
// JSONL line ids are source-local fallbacks, not provider identities. They
|
|
4064
|
+
// may reconcile semantically with a real app-server id; occurrence tracking
|
|
4065
|
+
// in the merge keeps intentional repeated rows distinct. Assistant messages
|
|
4066
|
+
// are exempt from the two-stable-ids refusal: the live-owner state keys them
|
|
4067
|
+
// by app-server event id (item_N) while the rollout records the provider id
|
|
4068
|
+
// (msg_...), so the same reply legitimately carries two stable identities.
|
|
4069
|
+
if (relayHistoryIdentityIsStable(firstIdentity)
|
|
4070
|
+
&& relayHistoryIdentityIsStable(secondIdentity)
|
|
4071
|
+
&& !(isRelayAssistantHistoryItem(first) && isRelayAssistantHistoryItem(second))) {
|
|
4072
|
+
return false;
|
|
4073
|
+
}
|
|
4074
|
+
if (relayHistoryIdentityIsStable(firstCallId)
|
|
4075
|
+
&& relayHistoryIdentityIsStable(secondCallId)) {
|
|
4076
|
+
return false;
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
const firstType = normalizeHistoryItemToken(first?.type);
|
|
4080
|
+
const secondType = normalizeHistoryItemToken(second?.type);
|
|
4081
|
+
if (firstType === "imageview" && secondType === "imageview") {
|
|
4082
|
+
const firstPath = normalizeImageViewPathKey(first);
|
|
4083
|
+
const secondPath = normalizeImageViewPathKey(second);
|
|
4084
|
+
if (firstPath && firstPath === secondPath) {
|
|
4085
|
+
return true;
|
|
3174
4086
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
4087
|
+
}
|
|
4088
|
+
if (firstType === "filechange" && secondType === "filechange") {
|
|
4089
|
+
const firstPaths = fileChangePathSet(first);
|
|
4090
|
+
const secondPaths = fileChangePathSet(second);
|
|
4091
|
+
if (firstPaths.size > 0
|
|
4092
|
+
&& firstPaths.size === secondPaths.size
|
|
4093
|
+
&& Array.from(firstPaths).every((pathKey) => secondPaths.has(pathKey))) {
|
|
3177
4094
|
return true;
|
|
3178
4095
|
}
|
|
3179
|
-
|
|
3180
|
-
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
const firstText = relayHistoryItemText(first);
|
|
4099
|
+
const secondText = relayHistoryItemText(second);
|
|
4100
|
+
if (!firstText || !secondText || firstText !== secondText) {
|
|
4101
|
+
return false;
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
return relayHistoryItemKindsCompatible(first, second);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
function relayHistoryItemKindsCompatible(first, second) {
|
|
4108
|
+
const firstType = normalizeHistoryItemToken(first?.type);
|
|
4109
|
+
const secondType = normalizeHistoryItemToken(second?.type);
|
|
4110
|
+
if (firstType && secondType && firstType === secondType) {
|
|
4111
|
+
return true;
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
const firstRole = normalizeNonEmptyString(first?.role).toLowerCase();
|
|
4115
|
+
const secondRole = normalizeNonEmptyString(second?.role).toLowerCase();
|
|
4116
|
+
if (firstRole && secondRole && firstRole === secondRole) {
|
|
4117
|
+
return true;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
return isRelayMessageLikeHistoryType(firstType) && isRelayMessageLikeHistoryType(secondType);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
function isRelayMessageLikeHistoryType(itemType) {
|
|
4124
|
+
return itemType === "message"
|
|
4125
|
+
|| itemType === "assistantmessage"
|
|
4126
|
+
|| itemType === "agentmessage"
|
|
4127
|
+
|| itemType === "usermessage";
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
function relayHistoryItemIdentity(item) {
|
|
4131
|
+
return normalizeNonEmptyString(item?.id)
|
|
4132
|
+
|| normalizeNonEmptyString(item?.itemId)
|
|
4133
|
+
|| normalizeNonEmptyString(item?.item_id);
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
function relayHistoryIdentityIsStable(identity) {
|
|
4137
|
+
const normalizedIdentity = normalizeNonEmptyString(identity);
|
|
4138
|
+
if (!normalizedIdentity) {
|
|
4139
|
+
return false;
|
|
4140
|
+
}
|
|
4141
|
+
return !/^(?:user-message-line|response-item-line|apply-patch-line)-\d+$/.test(normalizedIdentity);
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
function relayHistoryItemCallId(item) {
|
|
4145
|
+
return normalizeNonEmptyString(item?.call_id)
|
|
4146
|
+
|| normalizeNonEmptyString(item?.callId);
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
function relayHistoryItemText(item) {
|
|
4150
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
4151
|
+
return "";
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
for (const key of ["text", "message", "summary", "output", "outputText", "output_text", "command"]) {
|
|
4155
|
+
const value = normalizeNonEmptyString(item[key]);
|
|
4156
|
+
if (value) {
|
|
4157
|
+
return value;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
if (Array.isArray(item.content)) {
|
|
4162
|
+
return item.content
|
|
4163
|
+
.map(relayHistoryItemText)
|
|
4164
|
+
.filter(Boolean)
|
|
4165
|
+
.join("\n");
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
return "";
|
|
3181
4169
|
}
|
|
3182
4170
|
|
|
3183
4171
|
function normalizeImageViewPathKey(item) {
|
|
@@ -3249,7 +4237,16 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
|
|
|
3249
4237
|
}
|
|
3250
4238
|
|
|
3251
4239
|
let itemDidChange = false;
|
|
3252
|
-
let sanitizedItem =
|
|
4240
|
+
let sanitizedItem = sanitizeUserRoleItem(item);
|
|
4241
|
+
if (!sanitizedItem) {
|
|
4242
|
+
turnDidChange = true;
|
|
4243
|
+
return null;
|
|
4244
|
+
}
|
|
4245
|
+
if (sanitizedItem !== item) {
|
|
4246
|
+
itemDidChange = true;
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
sanitizedItem = convertApplyPatchHistoryItem(sanitizedItem) || sanitizedItem;
|
|
3253
4250
|
if (sanitizedItem !== item) {
|
|
3254
4251
|
itemDidChange = true;
|
|
3255
4252
|
}
|
|
@@ -3287,7 +4284,7 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
|
|
|
3287
4284
|
}
|
|
3288
4285
|
|
|
3289
4286
|
return itemDidChange ? sanitizedItem : item;
|
|
3290
|
-
});
|
|
4287
|
+
}).filter(Boolean);
|
|
3291
4288
|
|
|
3292
4289
|
return turnDidChange
|
|
3293
4290
|
? {
|
|
@@ -3297,6 +4294,67 @@ function sanitizeRelayHistoryTurn(turn, threadId = "") {
|
|
|
3297
4294
|
: turn;
|
|
3298
4295
|
}
|
|
3299
4296
|
|
|
4297
|
+
// Compatibility predicate for callers that only need a drop/no-drop decision.
|
|
4298
|
+
// The full sanitizer below also rewrites mixed items without losing attachments.
|
|
4299
|
+
const LIVE_ITEM_LIFECYCLE_METHODS = new Set([
|
|
4300
|
+
"item/started",
|
|
4301
|
+
"item/updated",
|
|
4302
|
+
"item/completed",
|
|
4303
|
+
]);
|
|
4304
|
+
|
|
4305
|
+
function isContextualUserItemNotification(parsed) {
|
|
4306
|
+
const method = typeof parsed?.method === "string" ? parsed.method : "";
|
|
4307
|
+
if (!LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
|
|
4308
|
+
return false;
|
|
4309
|
+
}
|
|
4310
|
+
const item = parsed?.params?.item;
|
|
4311
|
+
if (!isUserRoleItem(item)) {
|
|
4312
|
+
return false;
|
|
4313
|
+
}
|
|
4314
|
+
return isContextualUserText(readUserItemText(item));
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
// Sanitizes both raw app-server item events and fallback user_message events
|
|
4318
|
+
// before they can become mobile bubbles. Structured attachments stay intact.
|
|
4319
|
+
function sanitizeLiveUserNotification(parsed) {
|
|
4320
|
+
if (!parsed || typeof parsed !== "object") {
|
|
4321
|
+
return parsed;
|
|
4322
|
+
}
|
|
4323
|
+
const method = typeof parsed.method === "string" ? parsed.method : "";
|
|
4324
|
+
if (LIVE_ITEM_LIFECYCLE_METHODS.has(method)) {
|
|
4325
|
+
const item = parsed?.params?.item;
|
|
4326
|
+
if (!isUserRoleItem(item)) {
|
|
4327
|
+
return parsed;
|
|
4328
|
+
}
|
|
4329
|
+
const sanitizedItem = sanitizeUserRoleItem(item);
|
|
4330
|
+
if (!sanitizedItem) {
|
|
4331
|
+
return null;
|
|
4332
|
+
}
|
|
4333
|
+
return sanitizedItem === item ? parsed : {
|
|
4334
|
+
...parsed,
|
|
4335
|
+
params: { ...parsed.params, item: sanitizedItem },
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
if (method !== "codex/event/user_message") {
|
|
4340
|
+
return parsed;
|
|
4341
|
+
}
|
|
4342
|
+
const key = typeof parsed?.params?.message === "string"
|
|
4343
|
+
? "message"
|
|
4344
|
+
: (typeof parsed?.params?.text === "string" ? "text" : "");
|
|
4345
|
+
if (!key) {
|
|
4346
|
+
return parsed;
|
|
4347
|
+
}
|
|
4348
|
+
const visible = visibleUserPromptText(parsed.params[key]);
|
|
4349
|
+
if (!visible) {
|
|
4350
|
+
return null;
|
|
4351
|
+
}
|
|
4352
|
+
return visible === parsed.params[key] ? parsed : {
|
|
4353
|
+
...parsed,
|
|
4354
|
+
params: { ...parsed.params, [key]: visible },
|
|
4355
|
+
};
|
|
4356
|
+
}
|
|
4357
|
+
|
|
3300
4358
|
function convertApplyPatchHistoryItem(item) {
|
|
3301
4359
|
const itemType = normalizeHistoryItemToken(item?.type);
|
|
3302
4360
|
const toolName = normalizeNonEmptyString(item?.name);
|
|
@@ -3608,12 +4666,17 @@ function parseBridgeJSON(value) {
|
|
|
3608
4666
|
}
|
|
3609
4667
|
}
|
|
3610
4668
|
|
|
3611
|
-
function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
4669
|
+
function trimThreadPayloadForRelay(parsed, explicitThread = undefined, options = {}) {
|
|
3612
4670
|
const thread = explicitThread ?? parsed?.result?.thread;
|
|
3613
4671
|
if (!parsed || !thread || typeof thread !== "object" || !Array.isArray(thread.turns)) {
|
|
3614
4672
|
return null;
|
|
3615
4673
|
}
|
|
3616
4674
|
|
|
4675
|
+
// Callers that pre-trimmed the turn window pass the dropped count and the original
|
|
4676
|
+
// first turn here so compaction markers keep reporting whole-thread numbers.
|
|
4677
|
+
const preOmittedTurnCount = Math.max(0, options.preOmittedTurnCount ?? 0);
|
|
4678
|
+
const compactionIdSource = options.compactionIdSource ?? null;
|
|
4679
|
+
|
|
3617
4680
|
let workingThread = thread;
|
|
3618
4681
|
let encoded = encodeRelayThreadPayload(parsed, workingThread);
|
|
3619
4682
|
if (encoded == null) {
|
|
@@ -3621,7 +4684,16 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3621
4684
|
}
|
|
3622
4685
|
|
|
3623
4686
|
if (Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
|
|
3624
|
-
|
|
4687
|
+
if (preOmittedTurnCount <= 0) {
|
|
4688
|
+
return explicitThread === undefined ? null : encoded;
|
|
4689
|
+
}
|
|
4690
|
+
const compactedThread = buildRelayHistoryCompactedThread(
|
|
4691
|
+
thread,
|
|
4692
|
+
buildRelayCompactedHistoryTurns(thread.turns, thread.turns, preOmittedTurnCount, compactionIdSource),
|
|
4693
|
+
preOmittedTurnCount,
|
|
4694
|
+
thread.turns.length
|
|
4695
|
+
);
|
|
4696
|
+
return encodeRelayThreadPayload(parsed, compactedThread) ?? encoded;
|
|
3625
4697
|
}
|
|
3626
4698
|
|
|
3627
4699
|
const turns = thread.turns;
|
|
@@ -3634,8 +4706,8 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3634
4706
|
}
|
|
3635
4707
|
const candidateThread = buildRelayHistoryCompactedThread(
|
|
3636
4708
|
thread,
|
|
3637
|
-
buildRelayCompactedHistoryTurns(turns, trimmedTurns),
|
|
3638
|
-
Math.max(0, turns.length - trimmedTurns.length),
|
|
4709
|
+
buildRelayCompactedHistoryTurns(turns, trimmedTurns, preOmittedTurnCount, compactionIdSource),
|
|
4710
|
+
preOmittedTurnCount + Math.max(0, turns.length - trimmedTurns.length),
|
|
3639
4711
|
trimmedTurns.length
|
|
3640
4712
|
);
|
|
3641
4713
|
encoded = encodeRelayThreadPayload(parsed, candidateThread);
|
|
@@ -3655,9 +4727,9 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3655
4727
|
while (trimmedItems.length > 1) {
|
|
3656
4728
|
trimmedItems = trimmedItems.slice(1);
|
|
3657
4729
|
const compactedTurnPrefix = buildRelayHistoryCompactionTurn(
|
|
3658
|
-
Math.max(0, turns.length - 1),
|
|
4730
|
+
preOmittedTurnCount + Math.max(0, turns.length - 1),
|
|
3659
4731
|
1,
|
|
3660
|
-
thread
|
|
4732
|
+
compactionIdSource ?? thread
|
|
3661
4733
|
);
|
|
3662
4734
|
const candidateThread = buildRelayHistoryCompactedThread(
|
|
3663
4735
|
thread,
|
|
@@ -3668,7 +4740,7 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3668
4740
|
...newestTurn,
|
|
3669
4741
|
items: trimmedItems,
|
|
3670
4742
|
}],
|
|
3671
|
-
Math.max(0, turns.length - 1),
|
|
4743
|
+
preOmittedTurnCount + Math.max(0, turns.length - 1),
|
|
3672
4744
|
1
|
|
3673
4745
|
);
|
|
3674
4746
|
encoded = encodeRelayThreadPayload(parsed, candidateThread);
|
|
@@ -3690,13 +4762,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3690
4762
|
let candidateThread = buildRelayHistoryCompactedThread(
|
|
3691
4763
|
thread,
|
|
3692
4764
|
[
|
|
3693
|
-
...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
|
|
4765
|
+
...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
|
|
3694
4766
|
{
|
|
3695
4767
|
...newestTurn,
|
|
3696
4768
|
items: [truncatedItem],
|
|
3697
4769
|
},
|
|
3698
4770
|
],
|
|
3699
|
-
Math.max(0, turns.length - 1),
|
|
4771
|
+
preOmittedTurnCount + Math.max(0, turns.length - 1),
|
|
3700
4772
|
1
|
|
3701
4773
|
);
|
|
3702
4774
|
encoded = encodeRelayThreadPayload(parsed, candidateThread);
|
|
@@ -3707,13 +4779,13 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
|
|
|
3707
4779
|
candidateThread = buildRelayHistoryCompactedThread(
|
|
3708
4780
|
thread,
|
|
3709
4781
|
[
|
|
3710
|
-
...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
|
|
4782
|
+
...buildRelayCompactedHistoryTurns(turns, [newestTurn], preOmittedTurnCount, compactionIdSource).slice(0, -1),
|
|
3711
4783
|
{
|
|
3712
4784
|
...newestTurn,
|
|
3713
4785
|
items: [compactHistoryItemForRelay(mostRecentItem, RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS)],
|
|
3714
4786
|
},
|
|
3715
4787
|
],
|
|
3716
|
-
Math.max(0, turns.length - 1),
|
|
4788
|
+
preOmittedTurnCount + Math.max(0, turns.length - 1),
|
|
3717
4789
|
1
|
|
3718
4790
|
);
|
|
3719
4791
|
return encodeRelayThreadPayload(parsed, candidateThread);
|
|
@@ -3753,6 +4825,32 @@ function trimTurnsListPayloadForRelay(parsed, turnsKey, originalRawMessage = nul
|
|
|
3753
4825
|
}
|
|
3754
4826
|
}
|
|
3755
4827
|
|
|
4828
|
+
// A bounded JSONL first page can still describe one exceptionally large
|
|
4829
|
+
// turn with many small items. Keep that provisional response relay-safe while
|
|
4830
|
+
// preserving its handoff flags/cursor; the canonical background page will
|
|
4831
|
+
// replace it with the authoritative history.
|
|
4832
|
+
if (result.remodexJsonlFallback === true) {
|
|
4833
|
+
for (const maxItems of [64, 16, 4, 1]) {
|
|
4834
|
+
for (const maxChars of [1_000, 0]) {
|
|
4835
|
+
const emergencyTurns = turns.map((turn) => (
|
|
4836
|
+
compactEmergencySingleTurnForRelay(turn, maxChars, maxItems)
|
|
4837
|
+
));
|
|
4838
|
+
const emergencyPayload = JSON.stringify({
|
|
4839
|
+
...parsed,
|
|
4840
|
+
result: {
|
|
4841
|
+
...result,
|
|
4842
|
+
[turnsKey]: emergencyTurns,
|
|
4843
|
+
remodexPageCompactedForRelay: true,
|
|
4844
|
+
remodexEmergencyJsonlPageForRelay: true,
|
|
4845
|
+
},
|
|
4846
|
+
});
|
|
4847
|
+
if (Buffer.byteLength(emergencyPayload, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
|
|
4848
|
+
return emergencyPayload;
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
|
|
3756
4854
|
return fallbackCompactedPayload ?? (originalRawMessage ?? encoded);
|
|
3757
4855
|
}
|
|
3758
4856
|
|
|
@@ -3779,12 +4877,12 @@ function buildRelayHistoryCompactedThread(thread, turns, omittedTurnCount, keptT
|
|
|
3779
4877
|
};
|
|
3780
4878
|
}
|
|
3781
4879
|
|
|
3782
|
-
function buildRelayCompactedHistoryTurns(allTurns, keptTurns) {
|
|
3783
|
-
const omittedTurnCount = Math.max(0, allTurns.length - keptTurns.length);
|
|
4880
|
+
function buildRelayCompactedHistoryTurns(allTurns, keptTurns, preOmittedTurnCount = 0, idSourceOverride = null) {
|
|
4881
|
+
const omittedTurnCount = preOmittedTurnCount + Math.max(0, allTurns.length - keptTurns.length);
|
|
3784
4882
|
const compactionTurn = buildRelayHistoryCompactionTurn(
|
|
3785
4883
|
omittedTurnCount,
|
|
3786
4884
|
keptTurns.length,
|
|
3787
|
-
allTurns[0]
|
|
4885
|
+
idSourceOverride ?? allTurns[0]
|
|
3788
4886
|
);
|
|
3789
4887
|
return compactionTurn ? [compactionTurn, ...keptTurns] : keptTurns;
|
|
3790
4888
|
}
|
|
@@ -3808,6 +4906,9 @@ function buildRelayHistoryCompactionTurn(omittedTurnCount, keptTurnCount, idSour
|
|
|
3808
4906
|
|
|
3809
4907
|
return {
|
|
3810
4908
|
id: `remodex-history-compacted-${baseId}`,
|
|
4909
|
+
// A status-less turn reads as interruptible/running to the phone's
|
|
4910
|
+
// turn-state snapshot, flagging idle heavy threads as "thinking".
|
|
4911
|
+
status: "completed",
|
|
3811
4912
|
remodexSynthetic: true,
|
|
3812
4913
|
remodexHistoryCompacted: true,
|
|
3813
4914
|
remodexOmittedTurnCount: omittedTurnCount,
|
|
@@ -3970,21 +5071,40 @@ function persistBridgePreferences(
|
|
|
3970
5071
|
});
|
|
3971
5072
|
}
|
|
3972
5073
|
|
|
5074
|
+
function shouldSuppressRolloutMirrorForThread(
|
|
5075
|
+
threadId,
|
|
5076
|
+
{ desktopIpcActionFollower = null, desktopIpcLiveOwner = null } = {},
|
|
5077
|
+
{ fallbackActivityAt = 0 } = {}
|
|
5078
|
+
) {
|
|
5079
|
+
// Desktop ownership is an expiring live lease, not a permanent boolean. A
|
|
5080
|
+
// stale IPC snapshot used to mute an actively growing rollout forever.
|
|
5081
|
+
const followerIsFresh = typeof desktopIpcActionFollower?.hasFreshLiveThreadState === "function"
|
|
5082
|
+
? desktopIpcActionFollower.hasFreshLiveThreadState(threadId, { fallbackActivityAt })
|
|
5083
|
+
: desktopIpcActionFollower?.hasLiveThreadState(threadId);
|
|
5084
|
+
const ownerIsFresh = typeof desktopIpcLiveOwner?.isFreshThreadOwned === "function"
|
|
5085
|
+
? desktopIpcLiveOwner.isFreshThreadOwned(threadId)
|
|
5086
|
+
: false;
|
|
5087
|
+
return Boolean(followerIsFresh) || Boolean(ownerIsFresh);
|
|
5088
|
+
}
|
|
5089
|
+
|
|
3973
5090
|
module.exports = {
|
|
3974
5091
|
buildThreadTurnsListRelaySanitizeContext,
|
|
3975
5092
|
buildHeartbeatBridgeStatus,
|
|
3976
|
-
|
|
3977
|
-
buildRelayAccessTokenHeaders,
|
|
3978
|
-
buildRelayUserAgentHeader,
|
|
5093
|
+
canonicalThreadTurnsListRequest,
|
|
3979
5094
|
createMacOSBridgeWakeAssertion,
|
|
5095
|
+
createThreadTurnsListFastPageCoordinator,
|
|
3980
5096
|
disableUnsupportedReasoningSummaryForTurnStart,
|
|
3981
5097
|
fetchAdaptiveThreadTurnsListForRelay,
|
|
3982
5098
|
hasRelayConnectionGoneStale,
|
|
3983
|
-
|
|
5099
|
+
isContextualUserItemNotification,
|
|
5100
|
+
maybeMergeLatestJsonlTurnIntoTurnsListResponse,
|
|
5101
|
+
normalizeTurnStartForCodex,
|
|
3984
5102
|
normalizeRelayBoundJsonRpcMessage,
|
|
3985
5103
|
persistBridgePreferences,
|
|
3986
5104
|
resolveJsonlTurnsListRolloutPathForFallback,
|
|
3987
5105
|
sanitizeLiveGeneratedImageMessageForRelay,
|
|
5106
|
+
sanitizeLiveUserNotification,
|
|
3988
5107
|
sanitizeThreadHistoryImagesForRelay,
|
|
5108
|
+
shouldSuppressRolloutMirrorForThread,
|
|
3989
5109
|
startBridge,
|
|
3990
5110
|
};
|