@makerbi/remodex 2.3.0 → 2.3.2
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 +23 -0
- package/src/desktop-ipc-action-follower.js +183 -5
- package/src/desktop-ipc-conversation-adapter.js +138 -2
- package/src/desktop-ipc-conversation-projector.js +147 -1
- package/src/push-notification-tracker.js +134 -0
- package/src/rollout-live-mirror.js +20 -0
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -148,6 +148,25 @@ const RELAY_TURNS_LIST_PREVIOUS_PAGINATION_RESULT_KEYS = new Set([
|
|
|
148
148
|
"previousCursor",
|
|
149
149
|
"previous_cursor",
|
|
150
150
|
]);
|
|
151
|
+
|
|
152
|
+
function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
|
|
153
|
+
const normalizedVersion = typeof version === "string" && version.trim()
|
|
154
|
+
? version.trim().replace(/\s+/g, "-")
|
|
155
|
+
: "dev";
|
|
156
|
+
return `RemodexBridge/${normalizedVersion}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildRelayAccessTokenHeaders(config = {}, env = process.env) {
|
|
160
|
+
const token = normalizeNonEmptyString(
|
|
161
|
+
config.relayAccessToken
|
|
162
|
+
|| env.REMODEX_RELAY_ACCESS_TOKEN
|
|
163
|
+
|| env.PHODEX_RELAY_ACCESS_TOKEN
|
|
164
|
+
);
|
|
165
|
+
return token
|
|
166
|
+
? { "x-remodex-relay-token": token }
|
|
167
|
+
: {};
|
|
168
|
+
}
|
|
169
|
+
|
|
151
170
|
const jsonlArtifactItemsCacheByThread = new Map();
|
|
152
171
|
const jsonlThreadCwdCacheByThread = new Map();
|
|
153
172
|
const FORWARDED_REQUEST_METHODS_MAX_SIZE = 500;
|
|
@@ -1078,8 +1097,10 @@ function startBridge({
|
|
|
1078
1097
|
},
|
|
1079
1098
|
// The relay uses this per-session secret to authenticate the first push registration.
|
|
1080
1099
|
headers: {
|
|
1100
|
+
"User-Agent": buildRelayUserAgentHeader(),
|
|
1081
1101
|
"x-role": "mac",
|
|
1082
1102
|
"x-notification-secret": notificationSecret,
|
|
1103
|
+
...buildRelayAccessTokenHeaders(config),
|
|
1083
1104
|
...buildMacRegistrationHeaders(deviceState, pairingSession),
|
|
1084
1105
|
},
|
|
1085
1106
|
});
|
|
@@ -5090,6 +5111,8 @@ function shouldSuppressRolloutMirrorForThread(
|
|
|
5090
5111
|
module.exports = {
|
|
5091
5112
|
buildThreadTurnsListRelaySanitizeContext,
|
|
5092
5113
|
buildHeartbeatBridgeStatus,
|
|
5114
|
+
buildRelayAccessTokenHeaders,
|
|
5115
|
+
buildRelayUserAgentHeader,
|
|
5093
5116
|
canonicalThreadTurnsListRequest,
|
|
5094
5117
|
createMacOSBridgeWakeAssertion,
|
|
5095
5118
|
createThreadTurnsListFastPageCoordinator,
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
createDesktopConversationProjector,
|
|
12
12
|
desktopTurnsShareLogicalIdentity,
|
|
13
13
|
matchDesktopTurnIdentityContinuities,
|
|
14
|
+
projectDesktopConversationStateToGoal,
|
|
14
15
|
projectDesktopConversationStateToThread,
|
|
15
16
|
} = require("./desktop-ipc-conversation-projector");
|
|
16
17
|
const {
|
|
@@ -41,7 +42,12 @@ const BACKGROUND_DISCONNECT_GRACE_MS = 30_000;
|
|
|
41
42
|
const MAX_ACTIVE_THREAD_IDS = 512;
|
|
42
43
|
const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
|
|
43
44
|
const REMODEX_LIVE_OWNER_SOURCE = "desktop-ipc-live-owner";
|
|
44
|
-
const DESKTOP_STATE_READ_METHODS = new Set([
|
|
45
|
+
const DESKTOP_STATE_READ_METHODS = new Set([
|
|
46
|
+
"thread/read",
|
|
47
|
+
"thread/resume",
|
|
48
|
+
"thread/turns/list",
|
|
49
|
+
"thread/goal/get",
|
|
50
|
+
]);
|
|
45
51
|
// Sidebar refreshes should also keep the Litter subscription alive. Without
|
|
46
52
|
// this, a phone with no selected chat never connects to the Desktop bus and
|
|
47
53
|
// cannot discover runs that started on the Mac.
|
|
@@ -53,12 +59,21 @@ const DESKTOP_TURNS_CURSOR_PREFIX = "remodex-desktop-turns:";
|
|
|
53
59
|
// (e.g. Desktop never saw the turn finish) and must not answer phone reads, or
|
|
54
60
|
// the phone shows a phantom running indicator until real history loads.
|
|
55
61
|
const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
|
|
62
|
+
const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
|
|
56
63
|
const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
|
|
57
64
|
"turn/start",
|
|
58
65
|
"turn/steer",
|
|
59
66
|
"turn/interrupt",
|
|
60
67
|
"thread/compact/start",
|
|
61
68
|
]);
|
|
69
|
+
// These mutations have no Codex Desktop follower command. Never leak them to
|
|
70
|
+
// the bridge's separate local app-server owner, which could create a competing
|
|
71
|
+
// owner for the same persisted thread.
|
|
72
|
+
const DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS = new Map([
|
|
73
|
+
["review/start", "Start this review in Codex Desktop."],
|
|
74
|
+
["thread/settings/update", "Change these thread settings in Codex Desktop."],
|
|
75
|
+
["thread/approveGuardianDeniedAction", "Approve this retry in Codex Desktop."],
|
|
76
|
+
]);
|
|
62
77
|
const ACTION_METHODS = new Set([
|
|
63
78
|
"item/commandExecution/requestApproval",
|
|
64
79
|
"item/fileChange/requestApproval",
|
|
@@ -240,6 +255,7 @@ function createDesktopIpcActionFollower({
|
|
|
240
255
|
const projectedLiveActiveTurnIdsByThreadId = new Map();
|
|
241
256
|
const desktopLiveLifecycleByThreadId = new Map();
|
|
242
257
|
const normalizedLiveIndexesByThreadId = new Map();
|
|
258
|
+
const normalizedReviewFingerprintsByThreadId = new Map();
|
|
243
259
|
const staleYieldedThreadIds = new Set();
|
|
244
260
|
const conversationProjector = createDesktopConversationProjector({ now });
|
|
245
261
|
const pendingRoutesByRequestId = new Map();
|
|
@@ -301,6 +317,7 @@ function createDesktopIpcActionFollower({
|
|
|
301
317
|
projectedLiveActiveTurnIdsByThreadId.delete(threadId);
|
|
302
318
|
desktopLiveLifecycleByThreadId.delete(threadId);
|
|
303
319
|
normalizedLiveIndexesByThreadId.delete(threadId);
|
|
320
|
+
normalizedReviewFingerprintsByThreadId.delete(threadId);
|
|
304
321
|
conversationProjector.remove(threadId);
|
|
305
322
|
queuedChangesByThreadId.delete(threadId);
|
|
306
323
|
baselineRecoveryStateByThreadId.delete(threadId);
|
|
@@ -372,6 +389,14 @@ function createDesktopIpcActionFollower({
|
|
|
372
389
|
remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
|
|
373
390
|
},
|
|
374
391
|
}));
|
|
392
|
+
// Guardian reviews on normalized-only history turns never appear in
|
|
393
|
+
// canonical thread/read history, so drain their overlays here just
|
|
394
|
+
// like syncProjectedConversationState does; otherwise a thread that
|
|
395
|
+
// stays idle after opening never delivers them.
|
|
396
|
+
emitNormalizedReviewOverlays(
|
|
397
|
+
threadId,
|
|
398
|
+
normalizedLiveIndexesByThreadId.get(threadId)
|
|
399
|
+
);
|
|
375
400
|
const output = conversationProjector.project(threadId, liveState, {
|
|
376
401
|
includeAllActiveTurns: true,
|
|
377
402
|
});
|
|
@@ -414,6 +439,27 @@ function createDesktopIpcActionFollower({
|
|
|
414
439
|
}
|
|
415
440
|
}
|
|
416
441
|
|
|
442
|
+
if (DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS.has(method)) {
|
|
443
|
+
const threadId = readThreadId(message?.params);
|
|
444
|
+
const mayBelongToDesktop = threadId
|
|
445
|
+
&& !liveOwnerThreadIds.has(threadId)
|
|
446
|
+
&& !isLocallyOwnedThread(threadId)
|
|
447
|
+
&& (isDesktopRoutableThread(threadId)
|
|
448
|
+
|| activeThreadIds.has(threadId)
|
|
449
|
+
|| ownershipProbeDeadlinesByThreadId.has(threadId)
|
|
450
|
+
|| pendingOwnershipProbeTokensByThreadId.has(threadId));
|
|
451
|
+
if (mayBelongToDesktop) {
|
|
452
|
+
sendApplicationResponse(JSON.stringify({
|
|
453
|
+
id: message?.id,
|
|
454
|
+
error: {
|
|
455
|
+
code: -32004,
|
|
456
|
+
message: DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS.get(method),
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
417
463
|
if (tryServeDesktopOwnedRead(message)) {
|
|
418
464
|
return true;
|
|
419
465
|
}
|
|
@@ -448,6 +494,7 @@ function createDesktopIpcActionFollower({
|
|
|
448
494
|
projectedLiveActiveTurnIdsByThreadId.clear();
|
|
449
495
|
desktopLiveLifecycleByThreadId.clear();
|
|
450
496
|
normalizedLiveIndexesByThreadId.clear();
|
|
497
|
+
normalizedReviewFingerprintsByThreadId.clear();
|
|
451
498
|
conversationProjector.reset();
|
|
452
499
|
pendingRoutesByRequestId.clear();
|
|
453
500
|
activeThreadIds.clear();
|
|
@@ -656,6 +703,7 @@ function createDesktopIpcActionFollower({
|
|
|
656
703
|
onNormalizedHistoryIndexRebuilt(threadId);
|
|
657
704
|
} else {
|
|
658
705
|
normalizedLiveIndexesByThreadId.delete(threadId);
|
|
706
|
+
normalizedReviewFingerprintsByThreadId.delete(threadId);
|
|
659
707
|
}
|
|
660
708
|
}
|
|
661
709
|
|
|
@@ -690,6 +738,7 @@ function createDesktopIpcActionFollower({
|
|
|
690
738
|
projectedLiveActiveTurnIdsByThreadId.clear();
|
|
691
739
|
desktopLiveLifecycleByThreadId.clear();
|
|
692
740
|
normalizedLiveIndexesByThreadId.clear();
|
|
741
|
+
normalizedReviewFingerprintsByThreadId.clear();
|
|
693
742
|
recoveringThreadIds.clear();
|
|
694
743
|
baselineRecoveryStateByThreadId.clear();
|
|
695
744
|
queuedChangesByThreadId.clear();
|
|
@@ -730,6 +779,7 @@ function createDesktopIpcActionFollower({
|
|
|
730
779
|
projectedLiveActiveTurnIdsByThreadId.delete(threadId);
|
|
731
780
|
desktopLiveLifecycleByThreadId.delete(threadId);
|
|
732
781
|
normalizedLiveIndexesByThreadId.delete(threadId);
|
|
782
|
+
normalizedReviewFingerprintsByThreadId.delete(threadId);
|
|
733
783
|
conversationProjector.remove(threadId);
|
|
734
784
|
queuedChangesByThreadId.delete(threadId);
|
|
735
785
|
baselineRecoveryStateByThreadId.delete(threadId);
|
|
@@ -759,6 +809,7 @@ function createDesktopIpcActionFollower({
|
|
|
759
809
|
projectedLiveActiveTurnIdsByThreadId.delete(threadId);
|
|
760
810
|
desktopLiveLifecycleByThreadId.delete(threadId);
|
|
761
811
|
normalizedLiveIndexesByThreadId.delete(threadId);
|
|
812
|
+
normalizedReviewFingerprintsByThreadId.delete(threadId);
|
|
762
813
|
conversationProjector.remove(threadId);
|
|
763
814
|
queuedChangesByThreadId.delete(threadId);
|
|
764
815
|
baselineRecoveryStateByThreadId.delete(threadId);
|
|
@@ -1015,6 +1066,13 @@ function createDesktopIpcActionFollower({
|
|
|
1015
1066
|
}
|
|
1016
1067
|
|
|
1017
1068
|
rememberActiveThread(threadId);
|
|
1069
|
+
if (method === "thread/goal/get") {
|
|
1070
|
+
sendApplicationResponse(JSON.stringify({
|
|
1071
|
+
id: message.id,
|
|
1072
|
+
result: { goal: projectDesktopConversationStateToGoal(threadId, rawState) },
|
|
1073
|
+
}));
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1018
1076
|
// Newer Litter snapshots keep materialized history in
|
|
1019
1077
|
// turnHistory.history.entitiesByKey while leaving the legacy top-level
|
|
1020
1078
|
// turns array empty or limited to only the current turn. The Desktop
|
|
@@ -1058,6 +1116,7 @@ function createDesktopIpcActionFollower({
|
|
|
1058
1116
|
// echoing the raw Desktop conversationState alongside it doubled
|
|
1059
1117
|
// heavy threads past the relay frame limit for nothing.
|
|
1060
1118
|
thread,
|
|
1119
|
+
...(method === "thread/resume" ? { remodexDesktopIpcMirror: true } : {}),
|
|
1061
1120
|
};
|
|
1062
1121
|
if (!result) {
|
|
1063
1122
|
return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
|
|
@@ -1252,6 +1311,10 @@ function createDesktopIpcActionFollower({
|
|
|
1252
1311
|
remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
|
|
1253
1312
|
},
|
|
1254
1313
|
}));
|
|
1314
|
+
emitNormalizedReviewOverlays(
|
|
1315
|
+
threadId,
|
|
1316
|
+
normalizedLiveIndexesByThreadId.get(threadId)
|
|
1317
|
+
);
|
|
1255
1318
|
emitDesktopSnapshotLifecycleTransition(threadId, liveState);
|
|
1256
1319
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1257
1320
|
return;
|
|
@@ -1262,6 +1325,10 @@ function createDesktopIpcActionFollower({
|
|
|
1262
1325
|
// baselines: preserve only turn lifecycle, then seed. Item diffs resume
|
|
1263
1326
|
// on subsequent patches and cannot replay hundreds of old rows.
|
|
1264
1327
|
emitDesktopSnapshotLifecycleTransition(threadId, liveState);
|
|
1328
|
+
emitNormalizedReviewOverlays(
|
|
1329
|
+
threadId,
|
|
1330
|
+
normalizedLiveIndexesByThreadId.get(threadId)
|
|
1331
|
+
);
|
|
1265
1332
|
conversationProjector.seed(threadId, liveState);
|
|
1266
1333
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1267
1334
|
return;
|
|
@@ -1271,6 +1338,10 @@ function createDesktopIpcActionFollower({
|
|
|
1271
1338
|
// is a source epoch change. Force a baseline + thread/replaced repair.
|
|
1272
1339
|
conversationProjector.remove(threadId);
|
|
1273
1340
|
}
|
|
1341
|
+
emitNormalizedReviewOverlays(
|
|
1342
|
+
threadId,
|
|
1343
|
+
normalizedLiveIndexesByThreadId.get(threadId)
|
|
1344
|
+
);
|
|
1274
1345
|
const output = conversationProjector.project(threadId, liveState);
|
|
1275
1346
|
if (resumedAfterStaleYield || output.type === "fullReplace" || output.type === "baseline") {
|
|
1276
1347
|
// fullReplace: synthesized turn ids just became real, stale rows must go.
|
|
@@ -1303,6 +1374,66 @@ function createDesktopIpcActionFollower({
|
|
|
1303
1374
|
rememberDesktopLiveProjection(threadId, liveState);
|
|
1304
1375
|
}
|
|
1305
1376
|
|
|
1377
|
+
function emitNormalizedReviewOverlays(threadId, index) {
|
|
1378
|
+
if (!index || !Array.isArray(index.pendingReviewOverlays)) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const overlays = index.pendingReviewOverlays.splice(0);
|
|
1382
|
+
const fingerprints = normalizedReviewFingerprintsByThreadId.get(threadId) || new Map();
|
|
1383
|
+
for (const overlay of overlays) {
|
|
1384
|
+
// Reviews on turns in the projected live tail may also be emitted by
|
|
1385
|
+
// projector output; that duplication is intentional. The projector only
|
|
1386
|
+
// re-emits items for active turns (and not after a silent reseed), so
|
|
1387
|
+
// suppressing the overlay here would permanently drop reviews on
|
|
1388
|
+
// completed tail turns. The phone upserts by reviewId, so duplicates
|
|
1389
|
+
// cost one redundant notification and nothing else.
|
|
1390
|
+
const item = overlay?.item;
|
|
1391
|
+
const review = item?.review && typeof item.review === "object" ? item.review : item;
|
|
1392
|
+
const reviewId = readString(item?.reviewId)
|
|
1393
|
+
|| readString(item?.id).replace(/^automatic-approval-review:/, "");
|
|
1394
|
+
const status = readString(review?.status);
|
|
1395
|
+
if (!reviewId || !status || !item?.action) {
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const fingerprint = createHash("sha256")
|
|
1399
|
+
.update(JSON.stringify({ status, item }))
|
|
1400
|
+
.digest("hex");
|
|
1401
|
+
if (fingerprints.get(reviewId) === fingerprint) {
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
// Refresh insertion order so the cache is a bounded per-thread LRU.
|
|
1405
|
+
fingerprints.delete(reviewId);
|
|
1406
|
+
fingerprints.set(reviewId, fingerprint);
|
|
1407
|
+
while (fingerprints.size > MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD) {
|
|
1408
|
+
const oldestReviewId = fingerprints.keys().next().value;
|
|
1409
|
+
fingerprints.delete(oldestReviewId);
|
|
1410
|
+
}
|
|
1411
|
+
sendApplicationResponse(JSON.stringify({
|
|
1412
|
+
method: normalizeToken(status) === "inprogress"
|
|
1413
|
+
? "item/autoApprovalReview/started"
|
|
1414
|
+
: "item/autoApprovalReview/completed",
|
|
1415
|
+
params: {
|
|
1416
|
+
threadId,
|
|
1417
|
+
turnId: overlay.turnId,
|
|
1418
|
+
reviewId,
|
|
1419
|
+
targetItemId: readString(item.targetItemId) || null,
|
|
1420
|
+
startedAtMs: item.startedAtMs ?? null,
|
|
1421
|
+
completedAtMs: item.completedAtMs ?? null,
|
|
1422
|
+
decisionSource: readString(item.decisionSource)
|
|
1423
|
+
|| readString(item?.event?.decision_source)
|
|
1424
|
+
|| null,
|
|
1425
|
+
review: cloneJSON(review),
|
|
1426
|
+
action: cloneJSON(item.action),
|
|
1427
|
+
remodexDesktopMirror: true,
|
|
1428
|
+
remodexDesktopIpcMirror: true,
|
|
1429
|
+
remodexGuardianRetrySupported: false,
|
|
1430
|
+
remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
|
|
1431
|
+
},
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
normalizedReviewFingerprintsByThreadId.set(threadId, fingerprints);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1306
1437
|
// Unopened chats only need run-state signals for the sidebar. Sending the
|
|
1307
1438
|
// projector's full bootstrap here would replay every historical item from
|
|
1308
1439
|
// every running Desktop chat onto the phone during a sidebar refresh.
|
|
@@ -1410,6 +1541,7 @@ function createDesktopIpcActionFollower({
|
|
|
1410
1541
|
projectedLiveActiveTurnIdsByThreadId.delete(threadId);
|
|
1411
1542
|
desktopLiveLifecycleByThreadId.delete(threadId);
|
|
1412
1543
|
normalizedLiveIndexesByThreadId.delete(threadId);
|
|
1544
|
+
normalizedReviewFingerprintsByThreadId.delete(threadId);
|
|
1413
1545
|
conversationProjector.remove(threadId);
|
|
1414
1546
|
syncProjectedActions(threadId, []);
|
|
1415
1547
|
}
|
|
@@ -1531,7 +1663,14 @@ function createDesktopIpcActionFollower({
|
|
|
1531
1663
|
.then(() => resolveFollowerRequestParams(route))
|
|
1532
1664
|
.then(async (resolvedParams) => {
|
|
1533
1665
|
if (route.method === "thread-follower-start-turn") {
|
|
1534
|
-
|
|
1666
|
+
try {
|
|
1667
|
+
await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
// The actual turn has not reached Desktop yet. Even if the settings
|
|
1670
|
+
// request timed out after being applied, continuing through the local
|
|
1671
|
+
// app-server is safe because there is no Desktop turn to duplicate.
|
|
1672
|
+
throw markDeliveryFailureError(error);
|
|
1673
|
+
}
|
|
1535
1674
|
}
|
|
1536
1675
|
return {
|
|
1537
1676
|
resolvedParams,
|
|
@@ -2414,6 +2553,7 @@ function createNormalizedLiveIndex(state) {
|
|
|
2414
2553
|
entryIndexByTurnId,
|
|
2415
2554
|
turnIdByEntityKey,
|
|
2416
2555
|
activeTurnIds: new Set(),
|
|
2556
|
+
pendingReviewOverlays: [],
|
|
2417
2557
|
hasHistoryOutsideRawTurns: Array.from(normalizedTurnIds).some(
|
|
2418
2558
|
(turnId) => !rawTurnIds.has(turnId)
|
|
2419
2559
|
),
|
|
@@ -2423,6 +2563,14 @@ function createNormalizedLiveIndex(state) {
|
|
|
2423
2563
|
if (turn && isActiveRawTurn(turn)) {
|
|
2424
2564
|
index.activeTurnIds.add(entry.id);
|
|
2425
2565
|
}
|
|
2566
|
+
if (turn && entry.rawIndex == null) {
|
|
2567
|
+
for (const item of Array.isArray(turn.items) ? turn.items : []) {
|
|
2568
|
+
if (normalizeToken(item?.type) === "automaticapprovalreview") {
|
|
2569
|
+
const overlay = { turnId: entry.id, item };
|
|
2570
|
+
index.pendingReviewOverlays.push(overlay);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2426
2574
|
}
|
|
2427
2575
|
return index;
|
|
2428
2576
|
}
|
|
@@ -2460,6 +2608,7 @@ function normalizedLiveIndexNeedsRebuild(change) {
|
|
|
2460
2608
|
|
|
2461
2609
|
function refreshTouchedNormalizedActiveTurns(index, state, change) {
|
|
2462
2610
|
const touchedTurnIds = new Set();
|
|
2611
|
+
const touchedReviewTurnIds = new Set();
|
|
2463
2612
|
for (const patch of Array.isArray(change?.patches) ? change.patches : []) {
|
|
2464
2613
|
const path = Array.isArray(patch?.path) ? patch.path : [];
|
|
2465
2614
|
if (path[0] === "turns" && Number.isInteger(path[1]) && path[2] === "status") {
|
|
@@ -2479,9 +2628,23 @@ function refreshTouchedNormalizedActiveTurns(index, state, change) {
|
|
|
2479
2628
|
touchedTurnIds.add(turnId);
|
|
2480
2629
|
}
|
|
2481
2630
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2631
|
+
if ((path[0] === "turnHistory" || path[0] === "turn_history")
|
|
2632
|
+
&& path[1] === "history"
|
|
2633
|
+
&& (path[2] === "entitiesByKey" || path[2] === "entities_by_key")
|
|
2634
|
+
&& path[4] === "items") {
|
|
2635
|
+
const turnId = index.turnIdByEntityKey.get(readString(path[3]));
|
|
2636
|
+
const entryIndex = turnId ? index.entryIndexByTurnId.get(turnId) : null;
|
|
2637
|
+
const entry = entryIndex == null ? null : index.entries[entryIndex];
|
|
2638
|
+
const turn = entry ? resolveIndexedTurn(state, entry) : null;
|
|
2639
|
+
const item = Number.isInteger(path[5]) && Array.isArray(turn?.items)
|
|
2640
|
+
? turn.items[path[5]]
|
|
2641
|
+
: null;
|
|
2642
|
+
const isStructuralItemPatch = path.length <= 6 || path[6] === "type";
|
|
2643
|
+
if (turnId && (isStructuralItemPatch
|
|
2644
|
+
|| normalizeToken(item?.type) === "automaticapprovalreview")) {
|
|
2645
|
+
touchedReviewTurnIds.add(turnId);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2485
2648
|
}
|
|
2486
2649
|
for (const turnId of touchedTurnIds) {
|
|
2487
2650
|
const entryIndex = index.entryIndexByTurnId.get(turnId);
|
|
@@ -2493,6 +2656,21 @@ function refreshTouchedNormalizedActiveTurns(index, state, change) {
|
|
|
2493
2656
|
index.activeTurnIds.delete(turnId);
|
|
2494
2657
|
}
|
|
2495
2658
|
}
|
|
2659
|
+
for (const turnId of touchedReviewTurnIds) {
|
|
2660
|
+
const entryIndex = index.entryIndexByTurnId.get(turnId);
|
|
2661
|
+
const entry = entryIndex == null ? null : index.entries[entryIndex];
|
|
2662
|
+
const turn = entry ? resolveIndexedTurn(state, entry) : null;
|
|
2663
|
+
if (!turn || entry?.rawIndex != null) {
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
for (const item of Array.isArray(turn.items) ? turn.items : []) {
|
|
2667
|
+
if (normalizeToken(item?.type) !== "automaticapprovalreview") {
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
const overlay = { turnId, item };
|
|
2671
|
+
index.pendingReviewOverlays.push(overlay);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2496
2674
|
}
|
|
2497
2675
|
|
|
2498
2676
|
function latestActiveRawTurn(state) {
|
|
@@ -220,6 +220,34 @@ function applyAppServerMessageToConversationState({
|
|
|
220
220
|
conversation.updatedAt = now();
|
|
221
221
|
return { threadId, changed: true };
|
|
222
222
|
}
|
|
223
|
+
case "thread/goal/updated": {
|
|
224
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
225
|
+
const goal = normalizeThreadGoal(message.params?.goal, threadId);
|
|
226
|
+
if (!threadId || !shouldOwnThread(threadId) || !goal) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
230
|
+
if (goal.status === "complete") {
|
|
231
|
+
conversation.threadGoal = null;
|
|
232
|
+
conversation.completedThreadGoal = goal;
|
|
233
|
+
} else {
|
|
234
|
+
conversation.threadGoal = goal;
|
|
235
|
+
conversation.completedThreadGoal = null;
|
|
236
|
+
}
|
|
237
|
+
conversation.updatedAt = now();
|
|
238
|
+
return { threadId, changed: true };
|
|
239
|
+
}
|
|
240
|
+
case "thread/goal/cleared": {
|
|
241
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
242
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
246
|
+
conversation.threadGoal = null;
|
|
247
|
+
conversation.completedThreadGoal = null;
|
|
248
|
+
conversation.updatedAt = now();
|
|
249
|
+
return { threadId, changed: true };
|
|
250
|
+
}
|
|
223
251
|
case "turn/started":
|
|
224
252
|
case "turn/completed": {
|
|
225
253
|
const threadId = readThreadIdFromParams(message.params);
|
|
@@ -322,6 +350,30 @@ function applyAppServerMessageToConversationState({
|
|
|
322
350
|
conversation.updatedAt = now();
|
|
323
351
|
return { threadId, changed: true };
|
|
324
352
|
}
|
|
353
|
+
case "item/autoApprovalReview/started":
|
|
354
|
+
case "item/autoApprovalReview/completed": {
|
|
355
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
356
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const item = automaticApprovalReviewItemFromParams(message.params);
|
|
360
|
+
if (!item) {
|
|
361
|
+
return { threadId, changed: false };
|
|
362
|
+
}
|
|
363
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
364
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
365
|
+
conversation,
|
|
366
|
+
params: message.params,
|
|
367
|
+
fallbackTurnIdsByThreadId,
|
|
368
|
+
now,
|
|
369
|
+
}), { now });
|
|
370
|
+
if (turn) {
|
|
371
|
+
upsertItem(turn, item);
|
|
372
|
+
turn.firstTurnWorkItemStartedAtMs = turn.firstTurnWorkItemStartedAtMs || now();
|
|
373
|
+
}
|
|
374
|
+
conversation.updatedAt = now();
|
|
375
|
+
return { threadId, changed: true };
|
|
376
|
+
}
|
|
325
377
|
case "item/agentMessage/delta":
|
|
326
378
|
case "item/plan/delta":
|
|
327
379
|
case "item/reasoning/summaryTextDelta":
|
|
@@ -582,6 +634,7 @@ function buildConversationTurn(turn, {
|
|
|
582
634
|
// Drop it here, position-independently, so no Desktop snapshot path leaks it
|
|
583
635
|
// as a user bubble regardless of where the app-server placed it in the turn.
|
|
584
636
|
builtTurn.items = builtTurn.items
|
|
637
|
+
.map(normalizeDesktopItemCompatibility)
|
|
585
638
|
.map(sanitizeUserMessageItem)
|
|
586
639
|
.filter(Boolean);
|
|
587
640
|
// Hydrated turns from thread/read carry the prompt as an item with empty
|
|
@@ -731,6 +784,34 @@ function sanitizeUserMessageItem(item) {
|
|
|
731
784
|
};
|
|
732
785
|
}
|
|
733
786
|
|
|
787
|
+
// Codex CLI 0.144.1 can omit receiverThreads from persisted collab tool calls,
|
|
788
|
+
// while the matching Desktop renderer reads that collection without a fallback.
|
|
789
|
+
// Keep the richer snapshots unchanged and synthesize lightweight references from
|
|
790
|
+
// receiverThreadIds for older/CLI-owned rollouts so opening them cannot crash.
|
|
791
|
+
function normalizeDesktopItemCompatibility(item) {
|
|
792
|
+
if (!item || typeof item !== "object" || normalizeToken(item.type) !== "collabagenttoolcall") {
|
|
793
|
+
return item;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const receiverThreads = Array.isArray(item.receiverThreads)
|
|
797
|
+
? item.receiverThreads
|
|
798
|
+
: [];
|
|
799
|
+
const receiverThreadIds = Array.isArray(item.receiverThreadIds)
|
|
800
|
+
? item.receiverThreadIds.map(readString).filter(Boolean)
|
|
801
|
+
: receiverThreads.map((entry) => readString(entry?.threadId)).filter(Boolean);
|
|
802
|
+
if (Array.isArray(item.receiverThreads) && Array.isArray(item.receiverThreadIds)) {
|
|
803
|
+
return item;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
...item,
|
|
808
|
+
receiverThreadIds,
|
|
809
|
+
receiverThreads: Array.isArray(item.receiverThreads)
|
|
810
|
+
? receiverThreads
|
|
811
|
+
: receiverThreadIds.map((threadId) => ({ threadId })),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
734
815
|
function isInitialPromptUserMessageItem(turn, item) {
|
|
735
816
|
if (!isUserMessageItem(item)) {
|
|
736
817
|
return false;
|
|
@@ -908,8 +989,10 @@ function upsertItem(turn, item) {
|
|
|
908
989
|
// user items too; no Codex UI renders it, so it must not reach the stream.
|
|
909
990
|
// Also evict any copy that slipped into the state before this filter existed.
|
|
910
991
|
const index = turn.items.findIndex((candidate) => readString(candidate?.id) === itemId);
|
|
911
|
-
const sanitizedItem = sanitizeUserMessageItem(item);
|
|
912
|
-
const existingItem = index >= 0
|
|
992
|
+
const sanitizedItem = sanitizeUserMessageItem(normalizeDesktopItemCompatibility(item));
|
|
993
|
+
const existingItem = index >= 0
|
|
994
|
+
? sanitizeUserMessageItem(normalizeDesktopItemCompatibility(turn.items[index]))
|
|
995
|
+
: null;
|
|
913
996
|
if (!sanitizedItem) {
|
|
914
997
|
if (index >= 0) {
|
|
915
998
|
turn.items.splice(index, 1);
|
|
@@ -1099,10 +1182,62 @@ function readTurnIdFromTurn(turn) {
|
|
|
1099
1182
|
|| readString(turn?.turn_id);
|
|
1100
1183
|
}
|
|
1101
1184
|
|
|
1185
|
+
function automaticApprovalReviewItemFromParams(params) {
|
|
1186
|
+
const reviewId = readString(params?.reviewId);
|
|
1187
|
+
const review = params?.review && typeof params.review === "object" ? params.review : null;
|
|
1188
|
+
const status = readString(review?.status);
|
|
1189
|
+
if (!reviewId || !status || !params?.action) {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
id: `automatic-approval-review:${reviewId}`,
|
|
1194
|
+
type: "automaticApprovalReview",
|
|
1195
|
+
reviewId,
|
|
1196
|
+
targetItemId: readString(params?.targetItemId) || null,
|
|
1197
|
+
status,
|
|
1198
|
+
startedAtMs: params?.startedAtMs ?? null,
|
|
1199
|
+
completedAtMs: params?.completedAtMs ?? null,
|
|
1200
|
+
decisionSource: readString(params?.decisionSource) || null,
|
|
1201
|
+
review: cloneJSON(review),
|
|
1202
|
+
action: cloneJSON(params.action),
|
|
1203
|
+
remodexGuardianRetrySupported: false,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1102
1207
|
function timestampSecondsToMs(value) {
|
|
1103
1208
|
return Number.isFinite(value) && value > 0 ? Math.round(value * 1000) : 0;
|
|
1104
1209
|
}
|
|
1105
1210
|
|
|
1211
|
+
function normalizeThreadGoal(value, fallbackThreadId = "") {
|
|
1212
|
+
if (!value || typeof value !== "object") {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
const threadId = readString(value.threadId) || readString(value.thread_id) || readString(fallbackThreadId);
|
|
1216
|
+
const objective = readString(value.objective);
|
|
1217
|
+
const statusByToken = {
|
|
1218
|
+
active: "active",
|
|
1219
|
+
paused: "paused",
|
|
1220
|
+
blocked: "blocked",
|
|
1221
|
+
usagelimited: "usageLimited",
|
|
1222
|
+
budgetlimited: "budgetLimited",
|
|
1223
|
+
complete: "complete",
|
|
1224
|
+
};
|
|
1225
|
+
const status = statusByToken[normalizeToken(value.status)] || "";
|
|
1226
|
+
if (!threadId || !objective || !status) {
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
return {
|
|
1230
|
+
threadId,
|
|
1231
|
+
objective,
|
|
1232
|
+
status,
|
|
1233
|
+
tokenBudget: value.tokenBudget ?? value.token_budget ?? null,
|
|
1234
|
+
tokensUsed: Number(value.tokensUsed ?? value.tokens_used) || 0,
|
|
1235
|
+
timeUsedSeconds: Number(value.timeUsedSeconds ?? value.time_used_seconds) || 0,
|
|
1236
|
+
createdAt: Number(value.createdAt ?? value.created_at) || 0,
|
|
1237
|
+
updatedAt: Number(value.updatedAt ?? value.updated_at) || 0,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1106
1241
|
const REQUEST_METHODS_WITH_THREAD = new Set([
|
|
1107
1242
|
"item/commandExecution/requestApproval",
|
|
1108
1243
|
"item/fileChange/requestApproval",
|
|
@@ -1123,6 +1258,7 @@ module.exports = {
|
|
|
1123
1258
|
createEmptyConversationState,
|
|
1124
1259
|
ensureConversationInMap,
|
|
1125
1260
|
mergeConversationTurnsFromThread,
|
|
1261
|
+
normalizeThreadGoal,
|
|
1126
1262
|
readThreadIdFromParams,
|
|
1127
1263
|
readTurnIdFromParams,
|
|
1128
1264
|
readTurnIdFromTurn,
|
|
@@ -172,6 +172,10 @@ function projectDesktopConversationStateToThread(threadId, rawState, { now = ()
|
|
|
172
172
|
return projectConversationState(threadId, rawState, { now }).thread;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
function projectDesktopConversationStateToGoal(threadId, rawState) {
|
|
176
|
+
return latestThreadGoal(rawState, threadId);
|
|
177
|
+
}
|
|
178
|
+
|
|
175
179
|
function projectConversationState(threadId, rawState, {
|
|
176
180
|
now = () => Date.now(),
|
|
177
181
|
turnCache = null,
|
|
@@ -215,9 +219,12 @@ function projectConversationState(threadId, rawState, {
|
|
|
215
219
|
turns,
|
|
216
220
|
};
|
|
217
221
|
|
|
222
|
+
const goal = latestThreadGoal(rawState, threadId);
|
|
223
|
+
|
|
218
224
|
return {
|
|
219
225
|
thread,
|
|
220
226
|
turns,
|
|
227
|
+
goal,
|
|
221
228
|
activeTurnId,
|
|
222
229
|
status: thread.status,
|
|
223
230
|
};
|
|
@@ -332,6 +339,9 @@ function bootstrapNotifications(
|
|
|
332
339
|
const notifications = includeThreadStarted && shouldEmitThreadStarted(projection.thread)
|
|
333
340
|
? [threadStartedNotification(projection.thread)]
|
|
334
341
|
: [];
|
|
342
|
+
if (projection.goal) {
|
|
343
|
+
notifications.push(threadGoalUpdatedNotification(threadId, projection.goal));
|
|
344
|
+
}
|
|
335
345
|
const activeTurns = includeAllActiveTurns
|
|
336
346
|
? projection.turns.filter((turn) => isActiveTurnStatus(turn.status))
|
|
337
347
|
: [projection.activeTurnId
|
|
@@ -367,12 +377,26 @@ function diffProjections(threadId, previousProjection, nextProjection) {
|
|
|
367
377
|
const notifications = [];
|
|
368
378
|
|
|
369
379
|
notifications.push(...diffThreadMetadata(previousProjection.thread, nextProjection.thread));
|
|
380
|
+
notifications.push(...diffThreadGoal(threadId, previousProjection.goal, nextProjection.goal));
|
|
370
381
|
notifications.push(...diffTurnLifecycle(threadId, previousProjection, nextProjection));
|
|
371
382
|
notifications.push(...diffTurnItems(threadId, previousProjection, nextProjection));
|
|
372
383
|
|
|
373
384
|
return notifications;
|
|
374
385
|
}
|
|
375
386
|
|
|
387
|
+
function diffThreadGoal(threadId, previousGoal, nextGoal) {
|
|
388
|
+
if (JSON.stringify(previousGoal || null) === JSON.stringify(nextGoal || null)) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
if (nextGoal) {
|
|
392
|
+
return [threadGoalUpdatedNotification(threadId, nextGoal)];
|
|
393
|
+
}
|
|
394
|
+
return [tagNotification({
|
|
395
|
+
method: "thread/goal/cleared",
|
|
396
|
+
params: { threadId },
|
|
397
|
+
})];
|
|
398
|
+
}
|
|
399
|
+
|
|
376
400
|
function diffThreadMetadata(previousThread, nextThread) {
|
|
377
401
|
const notifications = [];
|
|
378
402
|
const previousRuntimeRevision = Number(previousThread.runtimeSettingsRevision) || 0;
|
|
@@ -486,7 +510,11 @@ function diffTurnItems(threadId, previousProjection, nextProjection) {
|
|
|
486
510
|
continue;
|
|
487
511
|
}
|
|
488
512
|
if (!isActiveTurn) {
|
|
489
|
-
|
|
513
|
+
if (isAutoApprovalReviewItem(nextItem) && !isTerminalItemState(nextItem)) {
|
|
514
|
+
notifications.push(itemStartedNotification(threadId, nextTurn.id, nextItem));
|
|
515
|
+
} else {
|
|
516
|
+
notifications.push(itemCompletedNotification(threadId, nextTurn.id, nextItem));
|
|
517
|
+
}
|
|
490
518
|
continue;
|
|
491
519
|
}
|
|
492
520
|
notifications.push(...diffItem(threadId, nextTurn.id, previousItem, nextItem));
|
|
@@ -502,6 +530,11 @@ function diffItem(threadId, turnId, previousItem, nextItem) {
|
|
|
502
530
|
// Previous text lengths come straight from the previous projection, which is
|
|
503
531
|
// exactly what the per-thread snapshot map used to store.
|
|
504
532
|
const snapshot = snapshotItem(previousItem);
|
|
533
|
+
if (isAutoApprovalReviewItem(nextItem)) {
|
|
534
|
+
return [isTerminalItemState(nextItem)
|
|
535
|
+
? itemCompletedNotification(threadId, turnId, nextItem)
|
|
536
|
+
: itemStartedNotification(threadId, turnId, nextItem)];
|
|
537
|
+
}
|
|
505
538
|
if (isAssistantMessageItem(nextItem)) {
|
|
506
539
|
const previousText = assistantMessageText(previousItem);
|
|
507
540
|
const nextText = assistantMessageText(nextItem);
|
|
@@ -644,6 +677,17 @@ function turnStartedNotification(threadId, turn) {
|
|
|
644
677
|
});
|
|
645
678
|
}
|
|
646
679
|
|
|
680
|
+
function threadGoalUpdatedNotification(threadId, goal) {
|
|
681
|
+
return tagNotification({
|
|
682
|
+
method: "thread/goal/updated",
|
|
683
|
+
params: {
|
|
684
|
+
threadId,
|
|
685
|
+
turnId: null,
|
|
686
|
+
goal: cloneJSON(goal),
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
647
691
|
function turnCompletedNotification(threadId, turn) {
|
|
648
692
|
return tagNotification({
|
|
649
693
|
method: "turn/completed",
|
|
@@ -658,6 +702,9 @@ function turnCompletedNotification(threadId, turn) {
|
|
|
658
702
|
}
|
|
659
703
|
|
|
660
704
|
function itemStartedNotification(threadId, turnId, item) {
|
|
705
|
+
if (isAutoApprovalReviewItem(item)) {
|
|
706
|
+
return autoApprovalReviewNotification("item/autoApprovalReview/started", threadId, turnId, item);
|
|
707
|
+
}
|
|
661
708
|
return tagNotification({
|
|
662
709
|
method: "item/started",
|
|
663
710
|
params: {
|
|
@@ -670,6 +717,9 @@ function itemStartedNotification(threadId, turnId, item) {
|
|
|
670
717
|
}
|
|
671
718
|
|
|
672
719
|
function itemCompletedNotification(threadId, turnId, item) {
|
|
720
|
+
if (isAutoApprovalReviewItem(item)) {
|
|
721
|
+
return autoApprovalReviewNotification("item/autoApprovalReview/completed", threadId, turnId, item);
|
|
722
|
+
}
|
|
673
723
|
return tagNotification({
|
|
674
724
|
method: "item/completed",
|
|
675
725
|
params: {
|
|
@@ -681,6 +731,21 @@ function itemCompletedNotification(threadId, turnId, item) {
|
|
|
681
731
|
});
|
|
682
732
|
}
|
|
683
733
|
|
|
734
|
+
// Guardian reviews have no ThreadItem variant in the app-server protocol; the
|
|
735
|
+
// live wire shape is the dedicated `item/autoApprovalReview/*` notification.
|
|
736
|
+
// Re-emit that shape so mobile reuses one decoder for owned and mirrored threads.
|
|
737
|
+
function autoApprovalReviewNotification(method, threadId, turnId, item) {
|
|
738
|
+
const { type, id, status, ...payload } = item;
|
|
739
|
+
return tagNotification({
|
|
740
|
+
method,
|
|
741
|
+
params: {
|
|
742
|
+
threadId,
|
|
743
|
+
turnId,
|
|
744
|
+
...cloneJSON(payload),
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
684
749
|
function deltaNotification(method, threadId, turnId, itemId, delta, extraParams = {}) {
|
|
685
750
|
return tagNotification({
|
|
686
751
|
method,
|
|
@@ -994,6 +1059,9 @@ function sanitizeUserInputEntries(entries) {
|
|
|
994
1059
|
// Keep the original type as metadata, but emit the generic shape iOS already decodes.
|
|
995
1060
|
// Returns null when the item has nothing user-visible left after sanitizing.
|
|
996
1061
|
function projectItemForMobile(item, itemType = normalizeToken(item?.type)) {
|
|
1062
|
+
if (itemType === "automaticapprovalreview") {
|
|
1063
|
+
return projectAutoApprovalReviewItem(item);
|
|
1064
|
+
}
|
|
997
1065
|
if (itemType === "usermessage") {
|
|
998
1066
|
const visibleContent = sanitizeUserInputEntries(
|
|
999
1067
|
Array.isArray(item?.content) ? item.content : []
|
|
@@ -1024,8 +1092,53 @@ function projectItemForMobile(item, itemType = normalizeToken(item?.type)) {
|
|
|
1024
1092
|
return projected;
|
|
1025
1093
|
}
|
|
1026
1094
|
|
|
1095
|
+
const AUTO_APPROVAL_REVIEW_ITEM_ID_PREFIX = "automatic-approval-review:";
|
|
1096
|
+
|
|
1097
|
+
function isAutoApprovalReviewItem(item) {
|
|
1098
|
+
return normalizeToken(item?.type) === "automaticapprovalreview";
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Desktop flattens `item/autoApprovalReview/*` notifications into synthetic
|
|
1102
|
+
// `automaticApprovalReview` turn items. Normalize back to the app-server
|
|
1103
|
+
// notification shape; keep top-level `status` for lifecycle checks.
|
|
1104
|
+
function projectAutoApprovalReviewItem(item) {
|
|
1105
|
+
const rawId = itemIdOf(item);
|
|
1106
|
+
if (!rawId) {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
const review = item?.review && typeof item.review === "object" ? item.review : item;
|
|
1110
|
+
const status = readString(review.status);
|
|
1111
|
+
if (!status) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
type: "automaticApprovalReview",
|
|
1116
|
+
id: rawId,
|
|
1117
|
+
reviewId: readString(item.reviewId)
|
|
1118
|
+
|| (rawId.startsWith(AUTO_APPROVAL_REVIEW_ITEM_ID_PREFIX)
|
|
1119
|
+
? rawId.slice(AUTO_APPROVAL_REVIEW_ITEM_ID_PREFIX.length)
|
|
1120
|
+
: rawId),
|
|
1121
|
+
targetItemId: readString(item.targetItemId) || null,
|
|
1122
|
+
status,
|
|
1123
|
+
startedAtMs: item.startedAtMs ?? null,
|
|
1124
|
+
completedAtMs: item.completedAtMs ?? null,
|
|
1125
|
+
decisionSource: readString(item.decisionSource)
|
|
1126
|
+
|| readString(item?.event?.decision_source)
|
|
1127
|
+
|| null,
|
|
1128
|
+
review: {
|
|
1129
|
+
status,
|
|
1130
|
+
riskLevel: readString(review.riskLevel) || null,
|
|
1131
|
+
userAuthorization: readString(review.userAuthorization) || null,
|
|
1132
|
+
rationale: readString(review.rationale) || null,
|
|
1133
|
+
},
|
|
1134
|
+
action: cloneJSON(item.action ?? null),
|
|
1135
|
+
...MIRROR_TAG,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1027
1139
|
function isSupportedItemType(type) {
|
|
1028
1140
|
return type === "usermessage"
|
|
1141
|
+
|| type === "automaticapprovalreview"
|
|
1029
1142
|
|| type === "hookprompt"
|
|
1030
1143
|
|| type === "agentmessage"
|
|
1031
1144
|
|| type === "assistantmessage"
|
|
@@ -1161,9 +1274,42 @@ function normalizeTimestamp(value) {
|
|
|
1161
1274
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
|
|
1162
1275
|
}
|
|
1163
1276
|
|
|
1277
|
+
function latestThreadGoal(rawState, threadId) {
|
|
1278
|
+
const candidates = [rawState?.threadGoal, rawState?.completedThreadGoal]
|
|
1279
|
+
.map((goal) => normalizeProjectedThreadGoal(goal, threadId))
|
|
1280
|
+
.filter(Boolean);
|
|
1281
|
+
return candidates.sort((left, right) => right.updatedAt - left.updatedAt)[0] || null;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function normalizeProjectedThreadGoal(value, fallbackThreadId) {
|
|
1285
|
+
if (!value || typeof value !== "object") {
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
const statusByToken = {
|
|
1289
|
+
active: "active",
|
|
1290
|
+
paused: "paused",
|
|
1291
|
+
blocked: "blocked",
|
|
1292
|
+
usagelimited: "usageLimited",
|
|
1293
|
+
budgetlimited: "budgetLimited",
|
|
1294
|
+
complete: "complete",
|
|
1295
|
+
};
|
|
1296
|
+
const goal = {
|
|
1297
|
+
threadId: readString(value.threadId) || readString(value.thread_id) || fallbackThreadId,
|
|
1298
|
+
objective: readString(value.objective),
|
|
1299
|
+
status: statusByToken[normalizeToken(value.status)] || "",
|
|
1300
|
+
tokenBudget: value.tokenBudget ?? value.token_budget ?? null,
|
|
1301
|
+
tokensUsed: Number(value.tokensUsed ?? value.tokens_used) || 0,
|
|
1302
|
+
timeUsedSeconds: Number(value.timeUsedSeconds ?? value.time_used_seconds) || 0,
|
|
1303
|
+
createdAt: Number(value.createdAt ?? value.created_at) || 0,
|
|
1304
|
+
updatedAt: Number(value.updatedAt ?? value.updated_at) || 0,
|
|
1305
|
+
};
|
|
1306
|
+
return goal.threadId && goal.objective && goal.status ? goal : null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1164
1309
|
module.exports = {
|
|
1165
1310
|
createDesktopConversationProjector,
|
|
1166
1311
|
desktopTurnsShareLogicalIdentity,
|
|
1167
1312
|
matchDesktopTurnIdentityContinuities,
|
|
1313
|
+
projectDesktopConversationStateToGoal,
|
|
1168
1314
|
projectDesktopConversationStateToThread,
|
|
1169
1315
|
};
|
|
@@ -4,14 +4,29 @@
|
|
|
4
4
|
// Exports: createPushNotificationTracker
|
|
5
5
|
// Depends on: ./push-notification-completion-dedupe
|
|
6
6
|
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
7
11
|
const {
|
|
8
12
|
createPushNotificationCompletionDedupe,
|
|
9
13
|
} = require("./push-notification-completion-dedupe");
|
|
10
14
|
|
|
15
|
+
const DEFAULT_GOAL_PUSH_STATE_PATH = path.join(os.homedir(), ".remodex", "goal-push-state.json");
|
|
16
|
+
|
|
11
17
|
const DEFAULT_PREVIEW_MAX_CHARS = 160;
|
|
12
18
|
const MAX_THREAD_TITLE_ENTRIES = 200;
|
|
13
19
|
const MAX_TURN_STATE_ENTRIES = 500;
|
|
14
20
|
const MAX_THREAD_ID_BY_TURN_ENTRIES = 500;
|
|
21
|
+
const MAX_GOAL_STATUS_ENTRIES = 500;
|
|
22
|
+
|
|
23
|
+
// Goal states worth waking the phone for: terminal or needs-user-attention.
|
|
24
|
+
const GOAL_PUSH_BODIES = new Map([
|
|
25
|
+
["complete", "Goal complete"],
|
|
26
|
+
["blocked", "Goal blocked — Codex needs your input"],
|
|
27
|
+
["usageLimited", "Goal stopped — usage limit reached"],
|
|
28
|
+
["budgetLimited", "Goal stopped — token budget reached"],
|
|
29
|
+
]);
|
|
15
30
|
|
|
16
31
|
function createPushNotificationTracker({
|
|
17
32
|
sessionId,
|
|
@@ -19,10 +34,14 @@ function createPushNotificationTracker({
|
|
|
19
34
|
previewMaxChars = DEFAULT_PREVIEW_MAX_CHARS,
|
|
20
35
|
logPrefix = "[remodex]",
|
|
21
36
|
now = () => Date.now(),
|
|
37
|
+
goalPushStatePath = DEFAULT_GOAL_PUSH_STATE_PATH,
|
|
22
38
|
} = {}) {
|
|
23
39
|
const threadTitleById = new Map();
|
|
24
40
|
const threadIdByTurnId = new Map();
|
|
25
41
|
const turnStateByKey = new Map();
|
|
42
|
+
// Persisted across bridge restarts so goal transitions that happened while the
|
|
43
|
+
// bridge was down still notify on the first post-restart snapshot.
|
|
44
|
+
const goalStatusByThreadId = loadGoalPushState(goalPushStatePath, logPrefix);
|
|
26
45
|
const completionDedupe = createPushNotificationCompletionDedupe({ now });
|
|
27
46
|
|
|
28
47
|
// ─── ENTRY POINT ─────────────────────────────────────────────
|
|
@@ -33,6 +52,18 @@ function createPushNotificationTracker({
|
|
|
33
52
|
return;
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
if (message.method === "thread/goal/updated") {
|
|
56
|
+
void handleGoalUpdated(message);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (message.method === "thread/goal/cleared") {
|
|
61
|
+
if (message.threadId && goalStatusByThreadId.delete(message.threadId)) {
|
|
62
|
+
saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
rememberMessageContext(message);
|
|
37
68
|
clearFallbackSuppressionForNewRun(message);
|
|
38
69
|
|
|
@@ -73,6 +104,59 @@ function createPushNotificationTracker({
|
|
|
73
104
|
}
|
|
74
105
|
}
|
|
75
106
|
|
|
107
|
+
// Pushes goal lifecycle transitions into terminal/attention states so hours-long
|
|
108
|
+
// background goals still reach the user. Resume snapshots (first observation of a
|
|
109
|
+
// status) never notify; only live status changes do.
|
|
110
|
+
async function handleGoalUpdated({ threadId, params }) {
|
|
111
|
+
const goal = objectValue(params?.goal);
|
|
112
|
+
const status = readString(goal?.status);
|
|
113
|
+
const resolvedThreadId = threadId || readString(goal?.threadId);
|
|
114
|
+
if (!resolvedThreadId || !status) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const previousSnapshot = normalizeGoalPushSnapshot(goalStatusByThreadId.get(resolvedThreadId));
|
|
119
|
+
const nextSnapshot = {
|
|
120
|
+
status,
|
|
121
|
+
updatedAt: goal?.updatedAt ?? goal?.updated_at ?? null,
|
|
122
|
+
};
|
|
123
|
+
if (!goalStatusByThreadId.has(resolvedThreadId) && goalStatusByThreadId.size >= MAX_GOAL_STATUS_ENTRIES) {
|
|
124
|
+
const oldest = goalStatusByThreadId.keys().next().value;
|
|
125
|
+
goalStatusByThreadId.delete(oldest);
|
|
126
|
+
}
|
|
127
|
+
const isFirstObservation = previousSnapshot == null;
|
|
128
|
+
const isDuplicate = previousSnapshot?.status === nextSnapshot.status
|
|
129
|
+
&& previousSnapshot?.updatedAt === nextSnapshot.updatedAt;
|
|
130
|
+
const body = GOAL_PUSH_BODIES.get(status);
|
|
131
|
+
if (isFirstObservation || isDuplicate || !body || !pushServiceClient?.hasConfiguredBaseUrl) {
|
|
132
|
+
if (!isDuplicate) {
|
|
133
|
+
goalStatusByThreadId.set(resolvedThreadId, nextSnapshot);
|
|
134
|
+
saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const title = normalizePreviewText(threadTitleById.get(resolvedThreadId)) || "New Thread";
|
|
140
|
+
// The goal objective intentionally stays out of push payloads and logs.
|
|
141
|
+
try {
|
|
142
|
+
await pushServiceClient.notifyCompletion({
|
|
143
|
+
threadId: resolvedThreadId,
|
|
144
|
+
turnId: null,
|
|
145
|
+
result: status === "complete" ? "completed" : "failed",
|
|
146
|
+
title,
|
|
147
|
+
body,
|
|
148
|
+
// updatedAt keeps repeated legitimate transitions (blocked -> active -> blocked) notifiable.
|
|
149
|
+
dedupeKey: [sessionId || "", resolvedThreadId, "goal", status, goal?.updatedAt ?? ""].join("|"),
|
|
150
|
+
});
|
|
151
|
+
// Commit the dedupe cursor only after delivery succeeds so a repeated
|
|
152
|
+
// app-server snapshot can retry a transient push outage.
|
|
153
|
+
goalStatusByThreadId.set(resolvedThreadId, nextSnapshot);
|
|
154
|
+
saveGoalPushState(goalPushStatePath, goalStatusByThreadId, logPrefix);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`${logPrefix} goal push notify failed: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
76
160
|
// Remembers thread/turn linkage before the terminal event arrives on a different payload shape.
|
|
77
161
|
function rememberMessageContext({ threadId, turnId, params, eventObject }) {
|
|
78
162
|
if (threadId && turnId) {
|
|
@@ -273,6 +357,56 @@ function createPushNotificationTracker({
|
|
|
273
357
|
};
|
|
274
358
|
}
|
|
275
359
|
|
|
360
|
+
// Best-effort disk persistence for goal statuses; failures must never break the bridge.
|
|
361
|
+
function loadGoalPushState(filePath, logPrefix) {
|
|
362
|
+
if (!filePath) {
|
|
363
|
+
return new Map();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
368
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
369
|
+
return new Map();
|
|
370
|
+
}
|
|
371
|
+
const entries = Object.entries(parsed)
|
|
372
|
+
.map(([threadId, snapshot]) => [threadId, normalizeGoalPushSnapshot(snapshot)])
|
|
373
|
+
.filter(([threadId, snapshot]) => typeof threadId === "string" && snapshot != null)
|
|
374
|
+
.slice(-MAX_GOAL_STATUS_ENTRIES);
|
|
375
|
+
return new Map(entries);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error.code !== "ENOENT") {
|
|
378
|
+
console.error(`${logPrefix} failed to load goal push state: ${error.message}`);
|
|
379
|
+
}
|
|
380
|
+
return new Map();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeGoalPushSnapshot(value) {
|
|
385
|
+
if (typeof value === "string") {
|
|
386
|
+
return { status: value, updatedAt: null };
|
|
387
|
+
}
|
|
388
|
+
if (!value || typeof value !== "object" || typeof value.status !== "string") {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
status: value.status,
|
|
393
|
+
updatedAt: value.updatedAt ?? null,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function saveGoalPushState(filePath, goalStatusByThreadId, logPrefix) {
|
|
398
|
+
if (!filePath) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
404
|
+
fs.writeFileSync(filePath, JSON.stringify(Object.fromEntries(goalStatusByThreadId)));
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error(`${logPrefix} failed to save goal push state: ${error.message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
276
410
|
// Normalizes the message envelope once so downstream helpers can share the same parsed view.
|
|
277
411
|
function parseOutboundMessage(rawMessage, parsedMessage = null) {
|
|
278
412
|
const parsed = parsedMessage ?? safeParseJSON(rawMessage);
|
|
@@ -799,6 +799,26 @@ function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.no
|
|
|
799
799
|
const payload = entry.payload || {};
|
|
800
800
|
const eventType = readString(payload.type);
|
|
801
801
|
|
|
802
|
+
if (eventType === "thread_goal_updated") {
|
|
803
|
+
const goal = payload.goal && typeof payload.goal === "object" ? payload.goal : null;
|
|
804
|
+
const threadId = readString(payload.threadId) || readString(goal?.threadId) || state.threadId;
|
|
805
|
+
if (!goal || !threadId) {
|
|
806
|
+
return [];
|
|
807
|
+
}
|
|
808
|
+
return [createNotification("thread/goal/updated", {
|
|
809
|
+
threadId,
|
|
810
|
+
turnId: readString(payload.turnId) || readString(payload.turn_id) || null,
|
|
811
|
+
goal,
|
|
812
|
+
})];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (eventType === "thread_goal_cleared") {
|
|
816
|
+
const threadId = readString(payload.threadId) || readString(payload.thread_id) || state.threadId;
|
|
817
|
+
return threadId
|
|
818
|
+
? [createNotification("thread/goal/cleared", { threadId })]
|
|
819
|
+
: [];
|
|
820
|
+
}
|
|
821
|
+
|
|
802
822
|
if (eventType === "task_started") {
|
|
803
823
|
notifications.push(...finalizePendingSyntheticTerminal(state));
|
|
804
824
|
const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
|