@makerbi/remodex 2.3.2 → 2.4.0

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