@makerbi/remodex 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bridge.js +34 -0
- package/src/desktop-ipc-action-follower.js +127 -82
- package/src/desktop-ipc-shared.js +148 -38
- package/src/rollout-live-mirror.js +150 -11
- package/src/session-jsonl-history.js +5 -1
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -55,6 +55,7 @@ const { createBridgeSecureTransport } = require("./secure-transport");
|
|
|
55
55
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
56
56
|
const {
|
|
57
57
|
isContextualUserText,
|
|
58
|
+
isThreadTurnStateProbeRequest,
|
|
58
59
|
isUserRoleItem,
|
|
59
60
|
readUserItemText,
|
|
60
61
|
sanitizeUserRoleItem,
|
|
@@ -577,6 +578,33 @@ function threadTurnsListHandoffDescriptor(cursor) {
|
|
|
577
578
|
return { anchorTurnId, token };
|
|
578
579
|
}
|
|
579
580
|
|
|
581
|
+
// The bounded canonical page can read a busy mirrored run as closed for a
|
|
582
|
+
// beat, which used to flap the phone's running state. When the rollout mirror
|
|
583
|
+
// is actively tailing a real turn, ride its id along on the turn-state probe
|
|
584
|
+
// as an advisory field; history pages stay untouched.
|
|
585
|
+
function annotateTurnStateProbeWithMirrorActiveTurn(request, response, getMirrorActiveTurnId) {
|
|
586
|
+
if (!isThreadTurnStateProbeRequest(request)) {
|
|
587
|
+
return response;
|
|
588
|
+
}
|
|
589
|
+
const params = request?.params || {};
|
|
590
|
+
const threadId = normalizeNonEmptyString(params.threadId)
|
|
591
|
+
|| normalizeNonEmptyString(params.thread_id);
|
|
592
|
+
const mirrorActiveTurnId = threadId ? getMirrorActiveTurnId?.(threadId) : null;
|
|
593
|
+
const result = response?.result;
|
|
594
|
+
if (!mirrorActiveTurnId || !result || typeof result !== "object" || Array.isArray(result)) {
|
|
595
|
+
return response;
|
|
596
|
+
}
|
|
597
|
+
// Page responses can come from the fast-page cache: never mutate a shared
|
|
598
|
+
// object, or the annotation would outlive the mirror on later replays.
|
|
599
|
+
return {
|
|
600
|
+
...response,
|
|
601
|
+
result: {
|
|
602
|
+
...result,
|
|
603
|
+
remodexMirrorActiveTurnId: mirrorActiveTurnId,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
580
608
|
function canonicalThreadTurnsListRequest(request) {
|
|
581
609
|
const params = { ...(request?.params || {}) };
|
|
582
610
|
delete params.remodexRequireCanonical;
|
|
@@ -1433,6 +1461,11 @@ function startBridge({
|
|
|
1433
1461
|
function sendBridgeManagedThreadTurnsListResponse(request, response, sendResponse, {
|
|
1434
1462
|
skipJsonlArtifactAugmentation = false,
|
|
1435
1463
|
} = {}) {
|
|
1464
|
+
response = annotateTurnStateProbeWithMirrorActiveTurn(
|
|
1465
|
+
request,
|
|
1466
|
+
response,
|
|
1467
|
+
(threadId) => rolloutLiveMirror?.getActiveTurnId(threadId) || null
|
|
1468
|
+
);
|
|
1436
1469
|
const finalSanitizeContext = buildThreadTurnsListRelaySanitizeContext(request, {
|
|
1437
1470
|
skipJsonlArtifactAugmentation,
|
|
1438
1471
|
});
|
|
@@ -5109,6 +5142,7 @@ function shouldSuppressRolloutMirrorForThread(
|
|
|
5109
5142
|
}
|
|
5110
5143
|
|
|
5111
5144
|
module.exports = {
|
|
5145
|
+
annotateTurnStateProbeWithMirrorActiveTurn,
|
|
5112
5146
|
buildThreadTurnsListRelaySanitizeContext,
|
|
5113
5147
|
buildHeartbeatBridgeStatus,
|
|
5114
5148
|
buildRelayAccessTokenHeaders,
|
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
FRAME_HEADER_BYTES,
|
|
20
20
|
MAX_FRAME_BYTES,
|
|
21
21
|
cloneJSON,
|
|
22
|
+
isThreadTurnStateProbeRequest,
|
|
22
23
|
normalizeToken,
|
|
23
24
|
readString,
|
|
24
25
|
requestIdKey,
|
|
@@ -36,7 +37,6 @@ const MAX_BASELINE_RECOVERY_ATTEMPTS = 5;
|
|
|
36
37
|
const BASELINE_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
37
38
|
const BASELINE_RECOVERY_MAX_DELAY_MS = 15_000;
|
|
38
39
|
const MAX_QUEUED_CHANGES_PER_THREAD = 300;
|
|
39
|
-
const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
|
|
40
40
|
// Phone interest survives per-thread release by design, so cap the set to keep a
|
|
41
41
|
// marathon single Desktop connection from accumulating every thread id forever.
|
|
42
42
|
const MAX_ACTIVE_THREAD_IDS = 512;
|
|
@@ -53,12 +53,13 @@ const DESKTOP_STATE_READ_METHODS = new Set([
|
|
|
53
53
|
// cannot discover runs that started on the Mac.
|
|
54
54
|
const DESKTOP_BACKGROUND_DISCOVERY_METHODS = new Set(["thread/list"]);
|
|
55
55
|
const DESKTOP_TURNS_CURSOR_PREFIX = "remodex-desktop-turns:";
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// often than this window; a silent "active" cache is a stale reconnect echo
|
|
59
|
-
// (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
|
|
60
|
-
// the phone shows a phantom running indicator until real history loads.
|
|
56
|
+
// Per-thread activity can be quiet during tools and subagents, so the short
|
|
57
|
+
// freshness window alone does not revoke a healthy Desktop owner.
|
|
61
58
|
const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
|
|
59
|
+
// An open Unix socket is not unlimited proof of a functioning publisher. If no
|
|
60
|
+
// frame arrives for this generous lease, yield stale active reads so canonical
|
|
61
|
+
// or rollout recovery can clear a phantom run.
|
|
62
|
+
const CONNECTED_IPC_ACTIVITY_LEASE_MS = 5 * 60_000;
|
|
62
63
|
const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
|
|
63
64
|
const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
|
|
64
65
|
"turn/start",
|
|
@@ -229,7 +230,6 @@ function createDesktopIpcActionFollower({
|
|
|
229
230
|
onNormalizedHistoryIndexRebuilt = () => {},
|
|
230
231
|
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
231
232
|
ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
|
|
232
|
-
backgroundDisconnectGraceMs = BACKGROUND_DISCONNECT_GRACE_MS,
|
|
233
233
|
} = {}) {
|
|
234
234
|
const ipc = createDesktopIpcClient({
|
|
235
235
|
socketPath,
|
|
@@ -268,7 +268,6 @@ function createDesktopIpcActionFollower({
|
|
|
268
268
|
// baseline. Otherwise a disconnect/eviction can erase the only evidence
|
|
269
269
|
// needed to send the matching completion and leave a phantom running badge.
|
|
270
270
|
const announcedBackgroundTurnsByThreadId = new Map();
|
|
271
|
-
const backgroundDisconnectTimersByThreadId = new Map();
|
|
272
271
|
// JS Set preserves insertion order; delete-before-add refreshes recency, and
|
|
273
272
|
// cap eviction skips threads with pending prompts so approvals are not lost.
|
|
274
273
|
function rememberActiveThread(threadId) {
|
|
@@ -364,7 +363,6 @@ function createDesktopIpcActionFollower({
|
|
|
364
363
|
} else {
|
|
365
364
|
settleAnnouncedBackgroundTurn(threadId, "interrupted");
|
|
366
365
|
}
|
|
367
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
368
366
|
announcedBackgroundTurnsByThreadId.delete(threadId);
|
|
369
367
|
if (rawState) {
|
|
370
368
|
const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
|
|
@@ -500,10 +498,6 @@ function createDesktopIpcActionFollower({
|
|
|
500
498
|
activeThreadIds.clear();
|
|
501
499
|
backgroundOnlyThreadIds.clear();
|
|
502
500
|
announcedBackgroundTurnsByThreadId.clear();
|
|
503
|
-
for (const timer of backgroundDisconnectTimersByThreadId.values()) {
|
|
504
|
-
clearTimeout(timer);
|
|
505
|
-
}
|
|
506
|
-
backgroundDisconnectTimersByThreadId.clear();
|
|
507
501
|
recoveringThreadIds.clear();
|
|
508
502
|
baselineRecoveryStateByThreadId.clear();
|
|
509
503
|
queuedChangesByThreadId.clear();
|
|
@@ -546,9 +540,6 @@ function createDesktopIpcActionFollower({
|
|
|
546
540
|
if (!threadId) {
|
|
547
541
|
return;
|
|
548
542
|
}
|
|
549
|
-
if (isSnapshotChange(params.change)) {
|
|
550
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
551
|
-
}
|
|
552
543
|
const peerOwnershipSnapshot = isPeerOwnershipSnapshot(params);
|
|
553
544
|
if (peerOwnershipSnapshot && !isLocallyOwnedThread(threadId)) {
|
|
554
545
|
liveOwnerThreadIds.delete(threadId);
|
|
@@ -744,9 +735,9 @@ function createDesktopIpcActionFollower({
|
|
|
744
735
|
queuedChangesByThreadId.clear();
|
|
745
736
|
pendingOwnershipProbeTokensByThreadId.clear();
|
|
746
737
|
desktopOwnedByProbeThreadIds.clear();
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
738
|
+
// A lost IPC connection is not evidence that Desktop stopped the turn.
|
|
739
|
+
// Keep announced lifecycle state until a reconnect snapshot, archive, or
|
|
740
|
+
// another authoritative state transition supplies a real terminal status.
|
|
750
741
|
// Keep activeThreadIds: phone interest is phone-scoped, not connection-scoped.
|
|
751
742
|
// Clearing it here would make reconnect snapshots for a thread the phone is
|
|
752
743
|
// still viewing fail the activeThreadIds.has() guard until the phone happens
|
|
@@ -1086,7 +1077,7 @@ function createDesktopIpcActionFollower({
|
|
|
1086
1077
|
canonicalHistoryThreadIds.add(threadId);
|
|
1087
1078
|
}
|
|
1088
1079
|
if (canonicalHistoryThreadIds.has(threadId)) {
|
|
1089
|
-
if (
|
|
1080
|
+
if (isThreadTurnStateProbeRequest(message)) {
|
|
1090
1081
|
const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
|
|
1091
1082
|
sendApplicationResponse(JSON.stringify({
|
|
1092
1083
|
id: message.id,
|
|
@@ -1100,11 +1091,13 @@ function createDesktopIpcActionFollower({
|
|
|
1100
1091
|
return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
|
|
1101
1092
|
}
|
|
1102
1093
|
const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1094
|
+
// While IPC remains connected, an explicitly active Desktop snapshot stays
|
|
1095
|
+
// authoritative through quiet tools, reasoning, approvals, and subagents.
|
|
1096
|
+
// Falling through to a second app-server here can replace a genuinely
|
|
1097
|
+
// running turn with stale idle history and make the phone clear Stop.
|
|
1106
1098
|
if (hasActiveProjectedTurn(thread)
|
|
1107
1099
|
&& isRawStateStaleForActiveRead(threadId)
|
|
1100
|
+
&& !hasResponsiveDesktopIpc()
|
|
1108
1101
|
&& !ownsDesktopCursor) {
|
|
1109
1102
|
staleYieldedThreadIds.add(threadId);
|
|
1110
1103
|
return false;
|
|
@@ -1139,21 +1132,6 @@ function createDesktopIpcActionFollower({
|
|
|
1139
1132
|
return true;
|
|
1140
1133
|
}
|
|
1141
1134
|
|
|
1142
|
-
function isDesktopLiveTurnStateSnapshotRequest(message) {
|
|
1143
|
-
if (readString(message?.method) !== "thread/turns/list"
|
|
1144
|
-
|| readString(message?.params?.cursor)
|
|
1145
|
-
|| message?.params?.remodexRequireCanonical === true) {
|
|
1146
|
-
return false;
|
|
1147
|
-
}
|
|
1148
|
-
if (message?.params?.remodexTurnStateOnly === true) {
|
|
1149
|
-
return true;
|
|
1150
|
-
}
|
|
1151
|
-
// Remodex iPhone 2.1 predates the explicit marker. Its running-state probe
|
|
1152
|
-
// has this unique request shape; actual history pages use limits 1 and 5.
|
|
1153
|
-
return Number(message?.params?.limit) === 8
|
|
1154
|
-
&& normalizeToken(readString(message?.params?.sortDirection) || "desc") === "desc";
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
1135
|
function buildDesktopLiveTurnStateResult(turns) {
|
|
1158
1136
|
const data = (Array.isArray(turns) ? turns : [])
|
|
1159
1137
|
.slice()
|
|
@@ -1215,6 +1193,10 @@ function createDesktopIpcActionFollower({
|
|
|
1215
1193
|
return now() - updatedAt > STALE_ACTIVE_READ_MAX_AGE_MS;
|
|
1216
1194
|
}
|
|
1217
1195
|
|
|
1196
|
+
function hasResponsiveDesktopIpc() {
|
|
1197
|
+
return ipc.hasRecentActivity(CONNECTED_IPC_ACTIVITY_LEASE_MS);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1218
1200
|
function boundedDesktopLiveStateForThread(threadId, state) {
|
|
1219
1201
|
return boundedDesktopLiveState(
|
|
1220
1202
|
state,
|
|
@@ -1242,6 +1224,8 @@ function createDesktopIpcActionFollower({
|
|
|
1242
1224
|
const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
|
|
1243
1225
|
const nextTurns = activeDesktopTurnDescriptors(liveState);
|
|
1244
1226
|
const nextById = new Map(nextTurns.map((turn) => [turn.id, turn]));
|
|
1227
|
+
const completedTurnIds = new Set();
|
|
1228
|
+
const startedTurnIds = new Set();
|
|
1245
1229
|
const {
|
|
1246
1230
|
previousTurnIds: continuityPreviousTurnIds,
|
|
1247
1231
|
nextTurnIds: continuityNextTurnIds,
|
|
@@ -1266,6 +1250,7 @@ function createDesktopIpcActionFollower({
|
|
|
1266
1250
|
threadId,
|
|
1267
1251
|
{ id: previous.id, status: terminalStatus }
|
|
1268
1252
|
)));
|
|
1253
|
+
completedTurnIds.add(previous.id);
|
|
1269
1254
|
}
|
|
1270
1255
|
for (const next of nextTurns) {
|
|
1271
1256
|
if (previousById.has(next.id)) {
|
|
@@ -1281,7 +1266,52 @@ function createDesktopIpcActionFollower({
|
|
|
1281
1266
|
? notificationWithTurnIdentityContinuity(startedNotification)
|
|
1282
1267
|
: startedNotification
|
|
1283
1268
|
));
|
|
1269
|
+
startedTurnIds.add(next.id);
|
|
1284
1270
|
}
|
|
1271
|
+
reannounceRemainingParallelTurn(
|
|
1272
|
+
threadId,
|
|
1273
|
+
previousById,
|
|
1274
|
+
nextTurns,
|
|
1275
|
+
completedTurnIds,
|
|
1276
|
+
startedTurnIds
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// iOS tracks one phone-visible active turn per thread. If parallel turn B was
|
|
1281
|
+
// the most recently announced turn and finishes while older A is still live,
|
|
1282
|
+
// B's completion clears that slot. Re-announce A as continuity so Stop and
|
|
1283
|
+
// running state stay attached to the existing run without advancing its
|
|
1284
|
+
// generation. A newly started replacement already performs this handoff.
|
|
1285
|
+
function reannounceRemainingParallelTurn(
|
|
1286
|
+
threadId,
|
|
1287
|
+
previousById,
|
|
1288
|
+
nextTurns,
|
|
1289
|
+
completedTurnIds,
|
|
1290
|
+
startedTurnIds
|
|
1291
|
+
) {
|
|
1292
|
+
const restorationTurnId = remainingParallelTurnRestorationId(
|
|
1293
|
+
previousById,
|
|
1294
|
+
nextTurns,
|
|
1295
|
+
completedTurnIds
|
|
1296
|
+
);
|
|
1297
|
+
if (!restorationTurnId || startedTurnIds.has(restorationTurnId)) {
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const nextVisibleTurn = nextTurns.find((turn) => turn.id === restorationTurnId);
|
|
1301
|
+
sendApplicationResponse(JSON.stringify(notificationWithTurnIdentityContinuity(
|
|
1302
|
+
desktopLiveTurnLifecycleNotification("turn/started", threadId, nextVisibleTurn)
|
|
1303
|
+
)));
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function remainingParallelTurnRestorationId(previousById, nextTurns, completedTurnIds) {
|
|
1307
|
+
const previousVisibleTurn = [...previousById.values()].at(-1);
|
|
1308
|
+
if (!previousVisibleTurn || !completedTurnIds.has(previousVisibleTurn.id)) {
|
|
1309
|
+
return "";
|
|
1310
|
+
}
|
|
1311
|
+
const nextVisibleTurn = nextTurns.at(-1);
|
|
1312
|
+
return nextVisibleTurn && previousById.has(nextVisibleTurn.id)
|
|
1313
|
+
? nextVisibleTurn.id
|
|
1314
|
+
: "";
|
|
1285
1315
|
}
|
|
1286
1316
|
|
|
1287
1317
|
function syncProjectedConversationState(threadId, nextState, { isFullSnapshot = false } = {}) {
|
|
@@ -1298,10 +1328,13 @@ function createDesktopIpcActionFollower({
|
|
|
1298
1328
|
&& !canonicalHistoryReplacementSentThreadIds.has(threadId)) {
|
|
1299
1329
|
canonicalHistoryReplacementSentThreadIds.add(threadId);
|
|
1300
1330
|
conversationProjector.remove(threadId);
|
|
1301
|
-
//
|
|
1302
|
-
// history.
|
|
1303
|
-
//
|
|
1304
|
-
|
|
1331
|
+
// Project the bounded active tail before asking the phone for canonical
|
|
1332
|
+
// history. On reconnect this snapshot may contain output produced while
|
|
1333
|
+
// IPC was unavailable; silently seeding it would permanently eat that
|
|
1334
|
+
// content when canonical JSONL/rollout history is still behind.
|
|
1335
|
+
const bootstrapOutput = conversationProjector.project(threadId, liveState, {
|
|
1336
|
+
includeAllActiveTurns: true,
|
|
1337
|
+
});
|
|
1305
1338
|
sendApplicationResponse(JSON.stringify({
|
|
1306
1339
|
method: "thread/replaced",
|
|
1307
1340
|
params: {
|
|
@@ -1316,6 +1349,11 @@ function createDesktopIpcActionFollower({
|
|
|
1316
1349
|
normalizedLiveIndexesByThreadId.get(threadId)
|
|
1317
1350
|
);
|
|
1318
1351
|
emitDesktopSnapshotLifecycleTransition(threadId, liveState);
|
|
1352
|
+
for (const notification of bootstrapOutput.notifications || []) {
|
|
1353
|
+
if (readString(notification?.method).startsWith("item/")) {
|
|
1354
|
+
sendApplicationResponse(JSON.stringify(notification));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1319
1357
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1320
1358
|
return;
|
|
1321
1359
|
}
|
|
@@ -1361,16 +1399,39 @@ function createDesktopIpcActionFollower({
|
|
|
1361
1399
|
},
|
|
1362
1400
|
}));
|
|
1363
1401
|
}
|
|
1364
|
-
|
|
1402
|
+
const outputNotifications = output.notifications || [];
|
|
1403
|
+
const previousById = desktopLiveLifecycleByThreadId.get(threadId) || new Map();
|
|
1404
|
+
const nextTurns = activeDesktopTurnDescriptors(liveState);
|
|
1405
|
+
const completedTurnIds = new Set(outputNotifications
|
|
1406
|
+
.filter((notification) => notification.method === "turn/completed")
|
|
1407
|
+
.map((notification) => readString(notification.params?.turnId))
|
|
1408
|
+
.filter(Boolean));
|
|
1409
|
+
const startedTurnIds = new Set(outputNotifications
|
|
1410
|
+
.filter((notification) => notification.method === "turn/started")
|
|
1411
|
+
.map((notification) => readString(notification.params?.turnId))
|
|
1412
|
+
.filter(Boolean));
|
|
1413
|
+
const parallelRestorationTurnId = remainingParallelTurnRestorationId(
|
|
1414
|
+
previousById,
|
|
1415
|
+
nextTurns,
|
|
1416
|
+
completedTurnIds
|
|
1417
|
+
);
|
|
1418
|
+
for (const notification of outputNotifications) {
|
|
1419
|
+
const notificationTurnId = readString(notification.params?.turnId);
|
|
1365
1420
|
const preservesTurnIdentity = notification.method === "turn/started"
|
|
1366
|
-
&&
|
|
1367
|
-
|
|
1368
|
-
);
|
|
1421
|
+
&& (parallelRestorationTurnId === notificationTurnId
|
|
1422
|
+
|| output.turnIdentityContinuityTurnIds?.includes(notificationTurnId));
|
|
1369
1423
|
const projectedNotification = preservesTurnIdentity
|
|
1370
1424
|
? notificationWithTurnIdentityContinuity(notification)
|
|
1371
1425
|
: notification;
|
|
1372
1426
|
sendApplicationResponse(JSON.stringify(projectedNotification));
|
|
1373
1427
|
}
|
|
1428
|
+
reannounceRemainingParallelTurn(
|
|
1429
|
+
threadId,
|
|
1430
|
+
previousById,
|
|
1431
|
+
nextTurns,
|
|
1432
|
+
completedTurnIds,
|
|
1433
|
+
startedTurnIds
|
|
1434
|
+
);
|
|
1374
1435
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1375
1436
|
}
|
|
1376
1437
|
|
|
@@ -1470,14 +1531,12 @@ function createDesktopIpcActionFollower({
|
|
|
1470
1531
|
nextActiveTurn
|
|
1471
1532
|
)));
|
|
1472
1533
|
announcedBackgroundTurnsByThreadId.set(threadId, nextActiveTurn);
|
|
1473
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
1474
1534
|
}
|
|
1475
1535
|
}
|
|
1476
1536
|
|
|
1477
1537
|
function settleAnnouncedBackgroundTurn(threadId, status = "interrupted", turn = null) {
|
|
1478
1538
|
const announcedTurn = announcedBackgroundTurnsByThreadId.get(threadId);
|
|
1479
1539
|
if (!announcedTurn) {
|
|
1480
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
1481
1540
|
return false;
|
|
1482
1541
|
}
|
|
1483
1542
|
const settledTurn = {
|
|
@@ -1492,36 +1551,9 @@ function createDesktopIpcActionFollower({
|
|
|
1492
1551
|
settledTurn
|
|
1493
1552
|
)));
|
|
1494
1553
|
announcedBackgroundTurnsByThreadId.delete(threadId);
|
|
1495
|
-
clearBackgroundDisconnectTimer(threadId);
|
|
1496
1554
|
return true;
|
|
1497
1555
|
}
|
|
1498
1556
|
|
|
1499
|
-
function scheduleBackgroundDisconnectSettlement(threadId) {
|
|
1500
|
-
if (!announcedBackgroundTurnsByThreadId.has(threadId)
|
|
1501
|
-
|| backgroundDisconnectTimersByThreadId.has(threadId)) {
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
const expectedTurnId = announcedBackgroundTurnsByThreadId.get(threadId)?.id;
|
|
1505
|
-
const timer = setTimeout(() => {
|
|
1506
|
-
backgroundDisconnectTimersByThreadId.delete(threadId);
|
|
1507
|
-
if (announcedBackgroundTurnsByThreadId.get(threadId)?.id !== expectedTurnId) {
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
1510
|
-
settleAnnouncedBackgroundTurn(threadId, "interrupted");
|
|
1511
|
-
}, Math.max(0, backgroundDisconnectGraceMs));
|
|
1512
|
-
timer.unref?.();
|
|
1513
|
-
backgroundDisconnectTimersByThreadId.set(threadId, timer);
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
function clearBackgroundDisconnectTimer(threadId) {
|
|
1517
|
-
const timer = backgroundDisconnectTimersByThreadId.get(threadId);
|
|
1518
|
-
if (!timer) {
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
clearTimeout(timer);
|
|
1522
|
-
backgroundDisconnectTimersByThreadId.delete(threadId);
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
1557
|
function syncThreadArchiveBroadcast(envelope) {
|
|
1526
1558
|
const params = envelope.params || {};
|
|
1527
1559
|
const threadId = readString(params.conversationId) || readString(params.conversation_id);
|
|
@@ -1914,7 +1946,9 @@ function createDesktopIpcActionFollower({
|
|
|
1914
1946
|
return false;
|
|
1915
1947
|
}
|
|
1916
1948
|
const thread = projectDesktopConversationStateToThread(normalizedThreadId, liveState, { now });
|
|
1917
|
-
if (hasActiveProjectedTurn(thread)
|
|
1949
|
+
if (hasActiveProjectedTurn(thread)
|
|
1950
|
+
&& isRawStateStaleForActiveRead(normalizedThreadId)
|
|
1951
|
+
&& !hasResponsiveDesktopIpc()) {
|
|
1918
1952
|
staleYieldedThreadIds.add(normalizedThreadId);
|
|
1919
1953
|
return false;
|
|
1920
1954
|
}
|
|
@@ -1945,7 +1979,7 @@ function createDesktopIpcActionFollower({
|
|
|
1945
1979
|
if (hasActiveProjectedTurn(thread)) {
|
|
1946
1980
|
const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
|
|
1947
1981
|
const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
|
|
1948
|
-
return
|
|
1982
|
+
return hasResponsiveDesktopIpc()
|
|
1949
1983
|
&& (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
|
|
1950
1984
|
}
|
|
1951
1985
|
return !isRawStateStaleForActiveRead(normalizedThreadId);
|
|
@@ -1967,6 +2001,7 @@ function createDesktopIpcClient({
|
|
|
1967
2001
|
let socket = null;
|
|
1968
2002
|
let clientId = "";
|
|
1969
2003
|
let isConnecting = false;
|
|
2004
|
+
let lastActivityAt = 0;
|
|
1970
2005
|
let readBuffer = Buffer.alloc(0);
|
|
1971
2006
|
const pendingRequests = new Map();
|
|
1972
2007
|
const pendingDiscoveries = new Map();
|
|
@@ -2078,6 +2113,9 @@ function createDesktopIpcClient({
|
|
|
2078
2113
|
}
|
|
2079
2114
|
|
|
2080
2115
|
function handleData(chunk) {
|
|
2116
|
+
if (chunk.length > 0) {
|
|
2117
|
+
lastActivityAt = now();
|
|
2118
|
+
}
|
|
2081
2119
|
readBuffer = Buffer.concat([readBuffer, chunk]);
|
|
2082
2120
|
while (readBuffer.length >= FRAME_HEADER_BYTES) {
|
|
2083
2121
|
const frameLength = readBuffer.readUInt32LE(0);
|
|
@@ -2153,6 +2191,7 @@ function createDesktopIpcClient({
|
|
|
2153
2191
|
socket = null;
|
|
2154
2192
|
clientId = "";
|
|
2155
2193
|
isConnecting = false;
|
|
2194
|
+
lastActivityAt = 0;
|
|
2156
2195
|
readBuffer = Buffer.alloc(0);
|
|
2157
2196
|
for (const waiter of pendingRequests.values()) {
|
|
2158
2197
|
clearTimeout(waiter.timeout);
|
|
@@ -2191,6 +2230,11 @@ function createDesktopIpcClient({
|
|
|
2191
2230
|
isConnected() {
|
|
2192
2231
|
return Boolean(socket && !socket.destroyed && clientId);
|
|
2193
2232
|
},
|
|
2233
|
+
hasRecentActivity(maxAgeMs) {
|
|
2234
|
+
return Boolean(socket && !socket.destroyed && clientId)
|
|
2235
|
+
&& lastActivityAt > 0
|
|
2236
|
+
&& now() - lastActivityAt <= Math.max(0, Number(maxAgeMs) || 0);
|
|
2237
|
+
},
|
|
2194
2238
|
sendRequest,
|
|
2195
2239
|
sendDiscoveryRequest,
|
|
2196
2240
|
close,
|
|
@@ -2717,7 +2761,8 @@ function boundedDesktopLiveTurns(
|
|
|
2717
2761
|
if (orderedTurns.length <= 1) {
|
|
2718
2762
|
return normalizeBoundedTurnsForRuntime(
|
|
2719
2763
|
orderedTurns.map((turn, index) => withStableProjectedTurnId(turn, index)),
|
|
2720
|
-
state
|
|
2764
|
+
state,
|
|
2765
|
+
retainedTurnIds
|
|
2721
2766
|
);
|
|
2722
2767
|
}
|
|
2723
2768
|
|
|
@@ -2744,7 +2789,7 @@ function boundedDesktopLiveTurns(
|
|
|
2744
2789
|
const selectedTurns = Array.from(selectedIndexes)
|
|
2745
2790
|
.sort((left, right) => left - right)
|
|
2746
2791
|
.map((index) => withStableProjectedTurnId(orderedTurns[index], index));
|
|
2747
|
-
return normalizeBoundedTurnsForRuntime(selectedTurns, state);
|
|
2792
|
+
return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
|
|
2748
2793
|
}
|
|
2749
2794
|
|
|
2750
2795
|
function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
|
|
@@ -2791,7 +2836,7 @@ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds)
|
|
|
2791
2836
|
return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
|
|
2792
2837
|
})
|
|
2793
2838
|
.filter(Boolean);
|
|
2794
|
-
return normalizeBoundedTurnsForRuntime(selectedTurns, state);
|
|
2839
|
+
return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
|
|
2795
2840
|
}
|
|
2796
2841
|
|
|
2797
2842
|
function resolveIndexedTurn(state, entry) {
|
|
@@ -2868,12 +2913,12 @@ function notificationWithTurnIdentityContinuity(notification) {
|
|
|
2868
2913
|
};
|
|
2869
2914
|
}
|
|
2870
2915
|
|
|
2871
|
-
function normalizeBoundedTurnsForRuntime(turns, state) {
|
|
2916
|
+
function normalizeBoundedTurnsForRuntime(turns, state, retainedTurnIds = new Set()) {
|
|
2872
2917
|
if (!isExplicitlyIdleDesktopRuntime(state)) {
|
|
2873
2918
|
return turns;
|
|
2874
2919
|
}
|
|
2875
2920
|
return turns.map((turn) => (
|
|
2876
|
-
isActiveRawTurn(turn)
|
|
2921
|
+
isActiveRawTurn(turn) && !retainedTurnIds.has(turnIdOf(turn))
|
|
2877
2922
|
? { ...turn, status: "completed" }
|
|
2878
2923
|
: turn
|
|
2879
2924
|
));
|
|
@@ -63,8 +63,10 @@ const LEGACY_CONTEXT_WARNING_PREFIXES = [
|
|
|
63
63
|
const LEGACY_APPLY_PATCH_WARNING_PREFIX = "Warning: apply_patch was requested via ";
|
|
64
64
|
const LEGACY_APPLY_PATCH_WARNING_SUFFIX = "Use the apply_patch tool instead of exec_command.";
|
|
65
65
|
const AGENTS_INSTRUCTIONS_PREFIX = "# AGENTS.md instructions";
|
|
66
|
-
const
|
|
67
|
-
const
|
|
66
|
+
const AGENTS_INSTRUCTIONS_BEGIN = "<instructions>";
|
|
67
|
+
const AGENTS_INSTRUCTIONS_END = "</instructions>";
|
|
68
|
+
const INTERNAL_CONTEXT_PREFIX_PATTERN = /^<codex_internal_context\s+source=(?:"[a-z][a-z0-9_]*"|'[a-z][a-z0-9_]*')>[\s\S]*?<\/codex_internal_context>/;
|
|
69
|
+
const EXTERNAL_CONTEXT_PREFIX_PATTERN = /^<external_([a-z0-9_-]+)>[\s\S]*?<\/external_\1>/;
|
|
68
70
|
const PROMPT_REQUEST_BEGIN = "## My request for Codex:";
|
|
69
71
|
const REVIEW_PROMPT_PREFIX = "## Code review guidelines:";
|
|
70
72
|
|
|
@@ -91,38 +93,116 @@ function stripImagePlaceholders(text) {
|
|
|
91
93
|
return IMAGE_PLACEHOLDER_TOKEN.test(withoutPairs.trim()) ? "" : withoutPairs;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
// Review envelopes contain a real request after the delimiter and are never
|
|
97
|
+
// wholly contextual, even when that request itself contains reserved markup.
|
|
98
|
+
function isReviewEnvelopeText(trimmed) {
|
|
99
|
+
return trimmed.startsWith(REVIEW_PROMPT_PREFIX) && trimmed.includes(PROMPT_REQUEST_BEGIN);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Consumes one runtime-owned fragment anchored at the start of `text` and
|
|
103
|
+
// returns what follows it, or null when the text does not open with one.
|
|
104
|
+
// Codex packs several fragments into a single user item, so the opening and
|
|
105
|
+
// closing markers routinely belong to different fragments (a desktop opener
|
|
106
|
+
// reads "<recommended_plugins>...</recommended_plugins>" + AGENTS.md
|
|
107
|
+
// instructions + "<environment_context>...</environment_context>"). Matching
|
|
108
|
+
// the blob as a whole classifies that item as visible and turns the entire
|
|
109
|
+
// injected preamble into the thread's first user bubble.
|
|
110
|
+
function consumeLeadingContextFragment(text) {
|
|
111
|
+
const lower = text.toLowerCase();
|
|
112
|
+
|
|
113
|
+
for (const [start, end] of CONTEXT_MARKER_PAIRS) {
|
|
114
|
+
if (!lower.startsWith(start)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const closeIndex = lower.indexOf(end, start.length);
|
|
118
|
+
// An unterminated marker is not a fragment we can bound. Leave it visible
|
|
119
|
+
// rather than swallow a message that merely opens with reserved markup.
|
|
120
|
+
return closeIndex === -1 ? null : text.slice(closeIndex + end.length);
|
|
99
121
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
return
|
|
122
|
+
|
|
123
|
+
const internal = INTERNAL_CONTEXT_PREFIX_PATTERN.exec(text);
|
|
124
|
+
if (internal) {
|
|
125
|
+
return text.slice(internal[0].length);
|
|
104
126
|
}
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
// fragment. Only classify the whole item as hidden when its final fragment
|
|
109
|
-
// is also runtime-owned; a following real user request must stay visible.
|
|
110
|
-
return normalized.endsWith("</instructions>")
|
|
111
|
-
|| CONTEXT_MARKER_PAIRS.some(([, end]) => normalized.endsWith(end));
|
|
112
|
-
}
|
|
113
|
-
if (CONTEXT_MARKER_PAIRS.some(([start, end]) => (
|
|
114
|
-
normalized.startsWith(start) && normalized.endsWith(end)
|
|
115
|
-
))) {
|
|
116
|
-
return true;
|
|
127
|
+
const external = EXTERNAL_CONTEXT_PREFIX_PATTERN.exec(text);
|
|
128
|
+
if (external) {
|
|
129
|
+
return text.slice(external[0].length);
|
|
117
130
|
}
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
|
|
132
|
+
if (lower.startsWith(AGENTS_INSTRUCTIONS_PREFIX.toLowerCase())) {
|
|
133
|
+
return consumeAgentsInstructionsFragment(text, lower);
|
|
120
134
|
}
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
|
|
136
|
+
// Unlike the marked fragments above, runtime warnings carry no closing marker
|
|
137
|
+
// and trail free-form runtime lines ("Shell cwd was reset to ..."), so there is
|
|
138
|
+
// no boundary to peel at. They are emitted as whole items that never contain a
|
|
139
|
+
// user request, hence consuming the remainder rather than bounding it.
|
|
140
|
+
if (LEGACY_CONTEXT_WARNING_PREFIXES.some((prefix) => text.startsWith(prefix))) {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
return text.startsWith(LEGACY_APPLY_PATCH_WARNING_PREFIX)
|
|
144
|
+
&& text.endsWith(LEGACY_APPLY_PATCH_WARNING_SUFFIX)
|
|
145
|
+
? ""
|
|
146
|
+
: null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function consumeAgentsInstructionsFragment(text, lower) {
|
|
150
|
+
const closeIndex = lower.indexOf(AGENTS_INSTRUCTIONS_END);
|
|
151
|
+
if (closeIndex >= 0) {
|
|
152
|
+
return consumeChainedInstructionsBlocks(text.slice(closeIndex + AGENTS_INSTRUCTIONS_END.length));
|
|
153
|
+
}
|
|
154
|
+
// Older runtimes emit the AGENTS.md body unwrapped and then append another
|
|
155
|
+
// registered fragment; that next marker is the only reliable boundary.
|
|
156
|
+
const nextMarkerIndex = CONTEXT_MARKER_PAIRS
|
|
157
|
+
.map(([start]) => lower.indexOf(start, 1))
|
|
158
|
+
.filter((index) => index > 0)
|
|
159
|
+
.sort((left, right) => left - right)[0];
|
|
160
|
+
return nextMarkerIndex === undefined ? null : text.slice(nextMarkerIndex);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A single "# AGENTS.md instructions" header can carry one <INSTRUCTIONS> block
|
|
164
|
+
// per nested AGENTS.md file. Stopping at the first close would leave the rest of
|
|
165
|
+
// the preamble in front of the real request, where no registered marker matches
|
|
166
|
+
// and the peeler gives up, turning the leftover into the first user bubble.
|
|
167
|
+
function consumeChainedInstructionsBlocks(text) {
|
|
168
|
+
let rest = text;
|
|
169
|
+
for (;;) {
|
|
170
|
+
const trimmed = rest.trimStart();
|
|
171
|
+
const lower = trimmed.toLowerCase();
|
|
172
|
+
if (!lower.startsWith(AGENTS_INSTRUCTIONS_BEGIN)) {
|
|
173
|
+
return rest;
|
|
174
|
+
}
|
|
175
|
+
const closeIndex = lower.indexOf(AGENTS_INSTRUCTIONS_END, AGENTS_INSTRUCTIONS_BEGIN.length);
|
|
176
|
+
// Unterminated: same rule as everywhere else, leave it visible rather than
|
|
177
|
+
// guess where the injected block ends.
|
|
178
|
+
if (closeIndex === -1) {
|
|
179
|
+
return rest;
|
|
180
|
+
}
|
|
181
|
+
rest = trimmed.slice(closeIndex + AGENTS_INSTRUCTIONS_END.length);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Peels every injected fragment off the front of an already trimmed message and
|
|
186
|
+
// returns the text the user actually typed (empty when nothing else remains).
|
|
187
|
+
function stripLeadingContextFragments(trimmed) {
|
|
188
|
+
let rest = trimmed;
|
|
189
|
+
while (rest) {
|
|
190
|
+
const remainder = consumeLeadingContextFragment(rest);
|
|
191
|
+
if (remainder === null) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
rest = remainder.trim();
|
|
195
|
+
}
|
|
196
|
+
return rest;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isContextualUserText(text) {
|
|
200
|
+
const raw = typeof text === "string" ? text : "";
|
|
201
|
+
const trimmed = stripImagePlaceholders(raw).trim();
|
|
202
|
+
if (!trimmed || isReviewEnvelopeText(trimmed)) {
|
|
203
|
+
return false;
|
|
123
204
|
}
|
|
124
|
-
return trimmed
|
|
125
|
-
&& trimmed.endsWith(LEGACY_APPLY_PATCH_WARNING_SUFFIX);
|
|
205
|
+
return stripLeadingContextFragments(trimmed) === "";
|
|
126
206
|
}
|
|
127
207
|
|
|
128
208
|
function decodeXmlText(text) {
|
|
@@ -173,14 +253,25 @@ function visibleUserPromptText(text) {
|
|
|
173
253
|
return "";
|
|
174
254
|
}
|
|
175
255
|
const cleaned = stripImagePlaceholders(text);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (isContextualUserText(cleaned)) {
|
|
256
|
+
const trimmed = cleaned.trim();
|
|
257
|
+
if (!trimmed) {
|
|
179
258
|
return "";
|
|
180
259
|
}
|
|
181
|
-
|
|
260
|
+
// Context bodies can contain the request delimiter as ordinary text. Peel the
|
|
261
|
+
// injected fragments off first so the delimiter cannot reveal hidden content,
|
|
262
|
+
// and so a real request that trails them survives instead of the whole blob.
|
|
263
|
+
const stripped = isReviewEnvelopeText(trimmed)
|
|
264
|
+
? trimmed
|
|
265
|
+
: stripLeadingContextFragments(trimmed);
|
|
266
|
+
if (!stripped) {
|
|
267
|
+
return "";
|
|
268
|
+
}
|
|
269
|
+
// Untouched prompts keep their original spacing so callers can still detect
|
|
270
|
+
// "nothing changed" by identity and skip cloning the item.
|
|
271
|
+
const body = stripped === trimmed ? cleaned : stripped;
|
|
272
|
+
const requestIndex = body.lastIndexOf(PROMPT_REQUEST_BEGIN);
|
|
182
273
|
if (requestIndex >= 0) {
|
|
183
|
-
const request =
|
|
274
|
+
const request = body.slice(requestIndex + PROMPT_REQUEST_BEGIN.length).trim();
|
|
184
275
|
// A few IDE/review exports end with the delimiter but omit its request
|
|
185
276
|
// suffix. They still contain a real visible prompt before that marker;
|
|
186
277
|
// returning an empty string made live mirroring erase the opener while
|
|
@@ -191,14 +282,14 @@ function visibleUserPromptText(text) {
|
|
|
191
282
|
if (request) {
|
|
192
283
|
return request;
|
|
193
284
|
}
|
|
194
|
-
const
|
|
195
|
-
return isContextualUserText(
|
|
285
|
+
const precedingBody = body.slice(0, requestIndex).trimEnd();
|
|
286
|
+
return isContextualUserText(precedingBody) ? "" : precedingBody;
|
|
196
287
|
}
|
|
197
|
-
const envelopeText = extractVisibleRuntimeEnvelope(
|
|
288
|
+
const envelopeText = extractVisibleRuntimeEnvelope(body);
|
|
198
289
|
if (envelopeText != null) {
|
|
199
290
|
return envelopeText;
|
|
200
291
|
}
|
|
201
|
-
return
|
|
292
|
+
return body;
|
|
202
293
|
}
|
|
203
294
|
|
|
204
295
|
// Sanitizes text fragments independently so a hidden fragment cannot cause a
|
|
@@ -327,6 +418,24 @@ function normalizeToken(value) {
|
|
|
327
418
|
: "";
|
|
328
419
|
}
|
|
329
420
|
|
|
421
|
+
// The phone's running-state probe, as opposed to a history page: the explicit
|
|
422
|
+
// marker on current clients, or the probe's unique legacy shape (Remodex iPhone
|
|
423
|
+
// 2.1 predates the marker, and real history pages use limits 1 and 5). Every
|
|
424
|
+
// live source answers this request, so they must all recognize it identically.
|
|
425
|
+
function isThreadTurnStateProbeRequest(message) {
|
|
426
|
+
const params = message?.params;
|
|
427
|
+
if (readString(message?.method) !== "thread/turns/list"
|
|
428
|
+
|| readString(params?.cursor)
|
|
429
|
+
|| params?.remodexRequireCanonical === true) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
if (params?.remodexTurnStateOnly === true) {
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
return Number(params?.limit) === 8
|
|
436
|
+
&& normalizeToken(readString(params?.sortDirection) || "desc") === "desc";
|
|
437
|
+
}
|
|
438
|
+
|
|
330
439
|
function cloneJSON(value) {
|
|
331
440
|
if (value == null) {
|
|
332
441
|
return value;
|
|
@@ -456,6 +565,7 @@ module.exports = {
|
|
|
456
565
|
hasVisiblePlanUpdate,
|
|
457
566
|
isContextualUserText,
|
|
458
567
|
isPlainJSONObject,
|
|
568
|
+
isThreadTurnStateProbeRequest,
|
|
459
569
|
isUserRoleItem,
|
|
460
570
|
normalizeToken,
|
|
461
571
|
readString,
|
|
@@ -132,9 +132,17 @@ function createRolloutLiveMirrorController({
|
|
|
132
132
|
mirrorsByThreadId.clear();
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// The real turn id this mirror is actively tailing, or null. Lets the bridge
|
|
136
|
+
// answer the phone's turn-state probe from mirror truth when the bounded
|
|
137
|
+
// canonical page reads a busy run as closed.
|
|
138
|
+
function getActiveTurnId(threadId) {
|
|
139
|
+
return mirrorsByThreadId.get(threadId)?.getActiveTurnId() || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
return {
|
|
136
143
|
observeInbound,
|
|
137
144
|
stopAll,
|
|
145
|
+
getActiveTurnId,
|
|
138
146
|
};
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -358,9 +366,30 @@ function createThreadRolloutLiveMirror({
|
|
|
358
366
|
onStop();
|
|
359
367
|
}
|
|
360
368
|
|
|
369
|
+
// Only a healthy, actively-tailed run with a real id counts: synthetic ids
|
|
370
|
+
// are not actionable app-server turn ids, and suppressed/awaiting states
|
|
371
|
+
// mean the mirror does not actually know what is running. While another live
|
|
372
|
+
// source owns the thread the tail keeps parsing with its emissions muted, so
|
|
373
|
+
// reporting that turn id would resurrect exactly the state the bridge muted.
|
|
374
|
+
function getActiveTurnId() {
|
|
375
|
+
if (
|
|
376
|
+
isStopped
|
|
377
|
+
|| wasSuppressed
|
|
378
|
+
|| state.isDesktopOrigin === false
|
|
379
|
+
|| state.awaitingCoherentBoundary
|
|
380
|
+
|| state.suppressLiveActivityUntilGrowth
|
|
381
|
+
|| state.activeTurnIdIsSynthetic
|
|
382
|
+
|| state.pendingSyntheticTerminalTurnId
|
|
383
|
+
) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
return state.activeTurnId || null;
|
|
387
|
+
}
|
|
388
|
+
|
|
361
389
|
return {
|
|
362
390
|
bump,
|
|
363
391
|
stop,
|
|
392
|
+
getActiveTurnId,
|
|
364
393
|
};
|
|
365
394
|
}
|
|
366
395
|
|
|
@@ -401,12 +430,30 @@ function bootstrapFromExistingRollout({
|
|
|
401
430
|
fsModule,
|
|
402
431
|
});
|
|
403
432
|
if (!bootstrapWindow) {
|
|
404
|
-
// The active run starts outside the bounded bootstrap window. Do not emit
|
|
405
|
-
// a plausible-looking tail: canonical history remains the baseline and
|
|
406
|
-
// this mirror will still consume future growth normally.
|
|
407
433
|
state.awaitingCoherentBoundary = true;
|
|
408
434
|
return;
|
|
409
435
|
}
|
|
436
|
+
if (!bootstrapWindow.coherent) {
|
|
437
|
+
// The active run starts outside the bounded bootstrap window. Do not emit
|
|
438
|
+
// a plausible-looking tail: canonical history remains the baseline. But do
|
|
439
|
+
// not go dark either — a long busy run would stop mirroring tool activity
|
|
440
|
+
// until its next turn boundary. Attach to the run in place instead, so
|
|
441
|
+
// growth from here on keeps streaming live.
|
|
442
|
+
const attached = attachToActiveRunFromTruncatedTail({
|
|
443
|
+
contents: bootstrapWindow.alignedContents,
|
|
444
|
+
boundary: bootstrapWindow.boundary,
|
|
445
|
+
state,
|
|
446
|
+
rolloutPath,
|
|
447
|
+
fsModule,
|
|
448
|
+
sendApplicationResponse,
|
|
449
|
+
nowMs,
|
|
450
|
+
staleActiveRunMaxAgeMs,
|
|
451
|
+
});
|
|
452
|
+
if (!attached) {
|
|
453
|
+
state.awaitingCoherentBoundary = true;
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
410
457
|
const { tailStart, contents: bootstrapContents } = bootstrapWindow;
|
|
411
458
|
let initialContents = bootstrapContents;
|
|
412
459
|
if (!initialContents) {
|
|
@@ -532,7 +579,8 @@ function bootstrapFromExistingRollout({
|
|
|
532
579
|
// Expands backwards only until the newest active task has its opening user
|
|
533
580
|
// message. Every expansion reads just the newly needed prefix, so a 30MB file
|
|
534
581
|
// is read at most once rather than once per retry. The hard cap keeps bootstrap
|
|
535
|
-
// work/memory bounded;
|
|
582
|
+
// work/memory bounded; a capped window without a coherent opener comes back
|
|
583
|
+
// with `coherent: false` and must never be replayed as history.
|
|
536
584
|
function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
|
|
537
585
|
const maxBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_MAX_BYTES);
|
|
538
586
|
let windowBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_TAIL_BYTES);
|
|
@@ -549,10 +597,16 @@ function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
|
|
|
549
597
|
// some legitimate system/continuation turns have no materialized user row.
|
|
550
598
|
// The opener requirement only protects a truncated tail.
|
|
551
599
|
if (!boundary.hasActiveRun || boundary.hasOpeningUser || tailStart === 0) {
|
|
552
|
-
return { tailStart, contents };
|
|
600
|
+
return { tailStart, contents, coherent: true };
|
|
553
601
|
}
|
|
554
602
|
if (windowBytes >= maxBytes || tailStart === 0) {
|
|
555
|
-
return
|
|
603
|
+
return {
|
|
604
|
+
tailStart,
|
|
605
|
+
contents,
|
|
606
|
+
coherent: false,
|
|
607
|
+
alignedContents,
|
|
608
|
+
boundary,
|
|
609
|
+
};
|
|
556
610
|
}
|
|
557
611
|
|
|
558
612
|
const nextWindowBytes = Math.min(maxBytes, windowBytes * 2);
|
|
@@ -584,12 +638,21 @@ function inspectBootstrapRunBoundary(contents) {
|
|
|
584
638
|
// closing terminal is evidence of an unknown active boundary, not permission
|
|
585
639
|
// to replay a partial conversation.
|
|
586
640
|
let unboundedActivitySinceTerminal = false;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
641
|
+
// Attach metadata for the incoherent-window case, so the caller never has to
|
|
642
|
+
// re-parse the (up to 64MB) window a second time.
|
|
643
|
+
let newestTaskStartedLineIndex = -1;
|
|
644
|
+
let lastEntryTimestamp = "";
|
|
645
|
+
|
|
646
|
+
const lines = contents.split("\n");
|
|
647
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
648
|
+
const parsed = safeParseJSON(lines[lineIndex].trim());
|
|
590
649
|
if (!parsed) {
|
|
591
650
|
continue;
|
|
592
651
|
}
|
|
652
|
+
const entryTimestamp = readString(parsed.timestamp);
|
|
653
|
+
if (entryTimestamp) {
|
|
654
|
+
lastEntryTimestamp = entryTimestamp;
|
|
655
|
+
}
|
|
593
656
|
const taskEventType = parsed?.type === "event_msg"
|
|
594
657
|
? readString(parsed?.payload?.type)
|
|
595
658
|
: "";
|
|
@@ -611,6 +674,7 @@ function inspectBootstrapRunBoundary(contents) {
|
|
|
611
674
|
hasOpeningUser = pendingUserBeforeStart;
|
|
612
675
|
hasTurnOutputSinceStart = false;
|
|
613
676
|
pendingUserBeforeStart = false;
|
|
677
|
+
newestTaskStartedLineIndex = lineIndex;
|
|
614
678
|
continue;
|
|
615
679
|
}
|
|
616
680
|
if (!activeTurnId) {
|
|
@@ -653,6 +717,8 @@ function inspectBootstrapRunBoundary(contents) {
|
|
|
653
717
|
return {
|
|
654
718
|
hasActiveRun: Boolean(activeTurnId) || unboundedActivitySinceTerminal,
|
|
655
719
|
hasOpeningUser,
|
|
720
|
+
newestTaskStartedLineIndex,
|
|
721
|
+
lastEntryTimestamp,
|
|
656
722
|
};
|
|
657
723
|
}
|
|
658
724
|
|
|
@@ -667,6 +733,58 @@ function isBootstrapNeutralRecord(entry, taskEventType = "") {
|
|
|
667
733
|
|| taskEventType === "context_updated";
|
|
668
734
|
}
|
|
669
735
|
|
|
736
|
+
// Attaches mid-run when the active run's opener is beyond the bounded window:
|
|
737
|
+
// nothing already in the tail is emitted (it stays canonical-history
|
|
738
|
+
// territory), but run state is hydrated so subsequent growth mirrors live.
|
|
739
|
+
// Returns false when the tail proves the visible runs all closed — trailing
|
|
740
|
+
// bytes then belong to an unknown older boundary and stay suppressed.
|
|
741
|
+
function attachToActiveRunFromTruncatedTail({
|
|
742
|
+
contents,
|
|
743
|
+
boundary,
|
|
744
|
+
state,
|
|
745
|
+
rolloutPath,
|
|
746
|
+
fsModule,
|
|
747
|
+
sendApplicationResponse,
|
|
748
|
+
nowMs,
|
|
749
|
+
staleActiveRunMaxAgeMs,
|
|
750
|
+
}) {
|
|
751
|
+
const newestTaskStartedIndex = boundary?.newestTaskStartedLineIndex ?? -1;
|
|
752
|
+
if (newestTaskStartedIndex >= 0) {
|
|
753
|
+
// Hydrate through the shared reducer so parallel-turn and terminal
|
|
754
|
+
// semantics stay authoritative for what is still open at EOF.
|
|
755
|
+
processRolloutLines(contents.split("\n").slice(newestTaskStartedIndex), state, () => {});
|
|
756
|
+
if (!state.activeTurnId) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
// Mid-turn tail without its task_started: adopt a synthetic turn. The
|
|
761
|
+
// first non-terminal event carrying the real id promotes it, and a
|
|
762
|
+
// mismatched terminal closes it via the synthetic-terminal path.
|
|
763
|
+
state.activeTurnId = buildSyntheticTurnId(state, { timestamp: boundary?.lastEntryTimestamp || "" });
|
|
764
|
+
state.activeTurnIdIsSynthetic = true;
|
|
765
|
+
state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, state.activeTurnId);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs)) {
|
|
769
|
+
// Same contract as the stale coherent bootstrap: stay silent until real
|
|
770
|
+
// growth proves the desktop process is alive again.
|
|
771
|
+
state.suppressLiveActivityUntilGrowth = true;
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// A hydrated run that already carries a pending synthetic terminal is
|
|
776
|
+
// closing, not running: announcing it as live would just be followed by the
|
|
777
|
+
// tick's synthetic turn/completed one grace period later.
|
|
778
|
+
if (!state.pendingSyntheticTerminalTurnId) {
|
|
779
|
+
sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
|
|
780
|
+
threadId: state.threadId,
|
|
781
|
+
turnId: state.activeTurnId,
|
|
782
|
+
id: state.activeTurnId,
|
|
783
|
+
})));
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
|
|
670
788
|
// After a bounded bootstrap cannot reach the old opener, consume only new
|
|
671
789
|
// bytes. A later real user+task_started boundary safely starts a new live run;
|
|
672
790
|
// everything before it remains canonical-history territory.
|
|
@@ -972,7 +1090,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.no
|
|
|
972
1090
|
return notifications;
|
|
973
1091
|
}
|
|
974
1092
|
|
|
975
|
-
if (itemType === "functioncalloutput") {
|
|
1093
|
+
if (itemType === "functioncalloutput" || itemType === "customtoolcalloutput") {
|
|
976
1094
|
notifications.push(...toolOutputNotifications(state, payload));
|
|
977
1095
|
return notifications;
|
|
978
1096
|
}
|
|
@@ -1336,6 +1454,16 @@ function customToolStartNotifications(state, payload) {
|
|
|
1336
1454
|
return notifications;
|
|
1337
1455
|
}
|
|
1338
1456
|
|
|
1457
|
+
// Custom tool calls settle through custom_tool_call_output. Without tracking
|
|
1458
|
+
// them the activity row never completes, so it lingers between command groups.
|
|
1459
|
+
if (!isCommandToolName(toolName) && !state.applyPatchCalls.has(callId)) {
|
|
1460
|
+
state.commandCalls.set(callId, {
|
|
1461
|
+
toolName,
|
|
1462
|
+
command: toolName,
|
|
1463
|
+
cwd: readString(state.sessionMeta?.cwd) || "",
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1339
1467
|
return [
|
|
1340
1468
|
...notifications,
|
|
1341
1469
|
createNotification("codex/event/background_event", {
|
|
@@ -1796,8 +1924,19 @@ function genericToolActivityMessage(toolName) {
|
|
|
1796
1924
|
}
|
|
1797
1925
|
}
|
|
1798
1926
|
|
|
1927
|
+
// Mirrors the wording of genericToolActivityMessage so the completion line
|
|
1928
|
+
// supersedes the start line instead of stacking a second row beside it.
|
|
1799
1929
|
function genericToolCompletionMessage(toolName) {
|
|
1800
|
-
|
|
1930
|
+
switch (readString(toolName).toLowerCase()) {
|
|
1931
|
+
case "apply_patch":
|
|
1932
|
+
return "Applied patch";
|
|
1933
|
+
case "write_stdin":
|
|
1934
|
+
return "Wrote to terminal";
|
|
1935
|
+
case "read_thread_terminal":
|
|
1936
|
+
return "Read terminal output";
|
|
1937
|
+
default:
|
|
1938
|
+
return `Completed ${readString(toolName)}`;
|
|
1939
|
+
}
|
|
1801
1940
|
}
|
|
1802
1941
|
|
|
1803
1942
|
function createNotification(method, params = {}) {
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
isContextualUserText,
|
|
11
11
|
isUserRoleItem,
|
|
12
12
|
responseItemMessageText: sharedResponseItemMessageText,
|
|
13
|
+
sanitizeUserRoleItem,
|
|
13
14
|
visibleUserPromptText,
|
|
14
15
|
} = require("./desktop-ipc-shared");
|
|
15
16
|
|
|
@@ -862,7 +863,10 @@ function normalizeResponseItemForHistory(payload, lineNumber, { cwd = "", toolCa
|
|
|
862
863
|
item.role = "assistant";
|
|
863
864
|
}
|
|
864
865
|
|
|
865
|
-
|
|
866
|
+
// A single user item can carry injected context next to the real request.
|
|
867
|
+
// Sanitize here so history readers (including the thread/read JSONL merge,
|
|
868
|
+
// which runs after the relay sanitizer) never rebuild the hidden fragments.
|
|
869
|
+
return isUserRoleItem(item) ? sanitizeUserRoleItem(item) : item;
|
|
866
870
|
}
|
|
867
871
|
|
|
868
872
|
function applyHistoryAssistantSourceAlias(item, turnId, occurrencesByBaseKey = new Map()) {
|