@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerbi/remodex",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
5
5
  "repository": {
6
6
  "type": "git",
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(["thread/read", "thread/resume", "thread/turns/list"]);
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
- await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
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
- if (touchedTurnIds.size === 0) {
2484
- return;
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 ? sanitizeUserMessageItem(turn.items[index]) : null;
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
- notifications.push(itemCompletedNotification(threadId, nextTurn.id, nextItem));
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);