@makerbi/remodex 3.2.0 → 3.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.
@@ -6,6 +6,8 @@
6
6
 
7
7
  const { createHash } = require("crypto");
8
8
  const net = require("net");
9
+ const { createThreadMutationQueue, runtimeSettingsPatch, hasOwn, normalizeThreadSettingsUpdate } = require("./codex-runtime-settings");
10
+ const { projectSemanticItem } = require("./thread-activity-projector");
9
11
 
10
12
  const {
11
13
  createDesktopConversationProjector,
@@ -73,6 +75,7 @@ const STALE_ACTIVE_READ_MAX_AGE_MS = 20_000;
73
75
  const CONNECTED_IPC_ACTIVITY_LEASE_MS = 5 * 60_000;
74
76
  const MAX_NORMALIZED_REVIEW_FINGERPRINTS_PER_THREAD = 128;
75
77
  const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
78
+ "thread/settings/update",
76
79
  "turn/start",
77
80
  "turn/steer",
78
81
  "turn/interrupt",
@@ -83,7 +86,6 @@ const DESKTOP_FOLLOWER_REQUEST_METHODS = new Set([
83
86
  // owner for the same persisted thread.
84
87
  const DESKTOP_OWNER_UNSUPPORTED_MUTATION_ERRORS = new Map([
85
88
  ["review/start", "Start this review in Codex Desktop."],
86
- ["thread/settings/update", "Change these thread settings in Codex Desktop."],
87
89
  ["thread/approveGuardianDeniedAction", "Approve this retry in Codex Desktop."],
88
90
  ]);
89
91
  const ACTION_METHODS = new Set([
@@ -97,7 +99,7 @@ const REPLY_METHOD_BY_ACTION_METHOD = new Map([
97
99
  ["item/commandExecution/requestApproval", "thread-follower-command-approval-decision"],
98
100
  ["item/fileChange/requestApproval", "thread-follower-file-approval-decision"],
99
101
  ["item/fileRead/requestApproval", "thread-follower-file-approval-decision"],
100
- ["item/permissions/requestApproval", "thread-follower-file-approval-decision"],
102
+ ["item/permissions/requestApproval", "thread-follower-permissions-request-approval-response"],
101
103
  ["item/tool/requestUserInput", "thread-follower-submit-user-input"],
102
104
  ]);
103
105
  const APPROVAL_DECISIONS = new Set(["accept", "acceptForSession", "decline", "cancel"]);
@@ -242,9 +244,12 @@ function createDesktopIpcActionFollower({
242
244
  clearTimeoutFn = clearTimeout,
243
245
  onNormalizedHistoryIndexRebuilt = () => {},
244
246
  onFollowerStateChanged = null,
247
+ onActivityObservation = null,
245
248
  requestTimeoutMs = REQUEST_TIMEOUT_MS,
246
249
  ownershipProbeTimeoutMs = OWNERSHIP_PROBE_TIMEOUT_MS,
247
250
  } = {}) {
251
+ let desktopSourceGeneration = 0;
252
+ const enqueueMutation = createThreadMutationQueue();
248
253
  const ipc = createDesktopIpcClient({
249
254
  socketPath,
250
255
  netModule,
@@ -253,6 +258,11 @@ function createDesktopIpcActionFollower({
253
258
  logPrefix,
254
259
  onEnvelope,
255
260
  onConnected() {
261
+ desktopSourceGeneration += 1;
262
+ onActivityObservation?.({
263
+ type: "connected",
264
+ sourceGeneration: desktopSourceGeneration,
265
+ });
256
266
  announceDesktopFollowForActiveThreads();
257
267
  probeHeldFollowerRequests();
258
268
  },
@@ -276,6 +286,8 @@ function createDesktopIpcActionFollower({
276
286
  const pendingRoutesByRequestId = new Map();
277
287
  const activeThreadIds = new Set();
278
288
  const desktopFollowThreadIds = new Set();
289
+ const backgroundCatalogThreadIds = new Set();
290
+ let backgroundCatalogPageSize = 0;
279
291
  const followerClientIdsByThreadId = new Map();
280
292
  // Threads discovered from Litter snapshots before the phone reads them.
281
293
  // Their raw state is retained for lifecycle detection, but their transcript
@@ -314,6 +326,7 @@ function createDesktopIpcActionFollower({
314
326
  }
315
327
 
316
328
  function unfollowDesktopThread(threadId) {
329
+ backgroundCatalogThreadIds.delete(threadId);
317
330
  if (!desktopFollowThreadIds.delete(threadId)) {
318
331
  return false;
319
332
  }
@@ -330,6 +343,57 @@ function createDesktopIpcActionFollower({
330
343
  }
331
344
  }
332
345
 
346
+ // Current Desktop sends initial snapshots only to explicit followers. Warm a
347
+ // bounded recent catalog window without opening chats or claiming a writer.
348
+ // The existing background path forwards lifecycle only, never transcript items.
349
+ function observeThreadListResponse(result, { limit = null } = {}) {
350
+ const rows = result?.data || result?.items || result?.threads;
351
+ if (!Array.isArray(rows)) {
352
+ return;
353
+ }
354
+ const candidates = new Set(rows
355
+ .map((thread) => readString(thread?.id))
356
+ .filter((threadId) => threadId && !isLocallyOwnedThread(threadId) && !liveOwnerThreadIds.has(threadId))
357
+ .slice(0, MAX_ACTIVE_THREAD_IDS));
358
+ // Foreground health probes request fewer rows than the sidebar. Keep those
359
+ // additive; only a request covering the established window may prune it.
360
+ const requestedPageSize = Number.isSafeInteger(limit) && limit > 0
361
+ ? Math.min(limit, MAX_ACTIVE_THREAD_IDS)
362
+ : null;
363
+ // limit: 1 is the phone's health probe, including before its first sidebar
364
+ // request. It must never establish or replace the catalog window.
365
+ const isForegroundProbe = requestedPageSize === 1;
366
+ const replacesCatalog = !isForegroundProbe && (requestedPageSize === null
367
+ ? !result.nextCursor && !result.hasMore
368
+ : requestedPageSize >= backgroundCatalogPageSize);
369
+ if (!isForegroundProbe) {
370
+ backgroundCatalogPageSize = Math.max(backgroundCatalogPageSize, requestedPageSize || 0);
371
+ }
372
+ if (replacesCatalog) {
373
+ for (const threadId of backgroundCatalogThreadIds) {
374
+ if (!candidates.has(threadId) && !announcedBackgroundTurnsByThreadId.has(threadId)) {
375
+ backgroundCatalogThreadIds.delete(threadId);
376
+ if (backgroundOnlyThreadIds.has(threadId) || !activeThreadIds.has(threadId)) {
377
+ unfollowDesktopThread(threadId);
378
+ }
379
+ }
380
+ }
381
+ }
382
+ for (const threadId of candidates) {
383
+ if (backgroundCatalogThreadIds.has(threadId) || desktopFollowThreadIds.has(threadId)) {
384
+ continue;
385
+ }
386
+ if (backgroundCatalogThreadIds.size >= MAX_ACTIVE_THREAD_IDS) {
387
+ break;
388
+ }
389
+ backgroundCatalogThreadIds.add(threadId);
390
+ if (!activeThreadIds.has(threadId)) {
391
+ backgroundOnlyThreadIds.add(threadId);
392
+ }
393
+ followDesktopThread(threadId);
394
+ }
395
+ }
396
+
333
397
  function oldestEvictableActiveThreadId() {
334
398
  for (const threadId of activeThreadIds) {
335
399
  if (!hasPendingProjectedActions(threadId)
@@ -372,6 +436,7 @@ function createDesktopIpcActionFollower({
372
436
  ownershipProbeDeadlinesByThreadId.delete(threadId);
373
437
  pendingOwnershipProbeTokensByThreadId.delete(threadId);
374
438
  desktopOwnedByProbeThreadIds.delete(threadId);
439
+ notifyActivityRemoval(threadId, "evicted");
375
440
  }
376
441
  const recoveringThreadIds = new Set();
377
442
  const queuedChangesByThreadId = new Map();
@@ -551,6 +616,8 @@ function createDesktopIpcActionFollower({
551
616
  unfollowDesktopThread(threadId);
552
617
  }
553
618
  desktopFollowThreadIds.clear();
619
+ backgroundCatalogThreadIds.clear();
620
+ backgroundCatalogPageSize = 0;
554
621
  activeThreadIds.clear();
555
622
  followerClientIdsByThreadId.clear();
556
623
  backgroundOnlyThreadIds.clear();
@@ -709,6 +776,7 @@ function createDesktopIpcActionFollower({
709
776
  }
710
777
  rawStatesByThreadId.set(threadId, speculativeState);
711
778
  rawStateUpdatedAtByThreadId.set(threadId, now());
779
+ notifyActivityState(threadId, speculativeState);
712
780
  if (!backgroundOnlyThreadIds.has(threadId)) {
713
781
  conversationProjector.seed(threadId, speculativeState);
714
782
  }
@@ -760,6 +828,7 @@ function createDesktopIpcActionFollower({
760
828
  releaseDesktopThreadState(threadId);
761
829
  return false;
762
830
  }
831
+ runtimeSettingsStore?.observeConversation?.(threadId, nextState);
763
832
  runtimeSettingsStore?.attachToConversation?.(threadId, nextState);
764
833
  if (isFullSnapshot) {
765
834
  rebuildNormalizedLiveIndex(threadId, nextState);
@@ -769,6 +838,7 @@ function createDesktopIpcActionFollower({
769
838
  const previousState = rawStatesByThreadId.get(threadId) || null;
770
839
  rawStatesByThreadId.set(threadId, nextState);
771
840
  rawStateUpdatedAtByThreadId.set(threadId, now());
841
+ notifyActivityState(threadId, nextState);
772
842
  // A usable state arrived: recovery bookkeeping and pre-baseline queued
773
843
  // patches are obsolete (snapshots replace state wholesale).
774
844
  baselineRecoveryStateByThreadId.delete(threadId);
@@ -814,6 +884,10 @@ function createDesktopIpcActionFollower({
814
884
  }
815
885
 
816
886
  function onDisconnect() {
887
+ onActivityObservation?.({
888
+ type: "disconnected",
889
+ sourceGeneration: Math.max(1, desktopSourceGeneration),
890
+ });
817
891
  // Patch baselines are connection-scoped (Desktop re-sends a snapshot after
818
892
  // reconnect), but the projector cache is not: keeping it lets the reconnect
819
893
  // snapshot diff against already-mirrored content instead of replaying it.
@@ -869,8 +943,15 @@ function createDesktopIpcActionFollower({
869
943
 
870
944
  const followers = followerClientIdsByThreadId.get(threadId) || new Set();
871
945
  if (params.following === true) {
946
+ // A Desktop renderer opening a chat is not phone transcript interest.
947
+ // Only an inbound phone read/resume may promote it out of background.
948
+ if (!activeThreadIds.has(threadId)) {
949
+ backgroundOnlyThreadIds.add(threadId);
950
+ }
872
951
  rememberActiveThread(threadId);
873
- followDesktopThread(threadId);
952
+ if (!desktopFollowThreadIds.has(threadId)) {
953
+ followDesktopThread(threadId);
954
+ }
874
955
  const wasUnfollowed = followers.size === 0;
875
956
  followers.add(clientId);
876
957
  followerClientIdsByThreadId.set(threadId, followers);
@@ -937,6 +1018,7 @@ function createDesktopIpcActionFollower({
937
1018
  conversationProjector.remove(threadId);
938
1019
  queuedChangesByThreadId.delete(threadId);
939
1020
  baselineRecoveryStateByThreadId.delete(threadId);
1021
+ notifyActivityRemoval(threadId, "ownership-handoff");
940
1022
  releaseHeldFollowerRequests(threadId, { toDesktop: false });
941
1023
  }
942
1024
 
@@ -968,6 +1050,7 @@ function createDesktopIpcActionFollower({
968
1050
  conversationProjector.remove(threadId);
969
1051
  queuedChangesByThreadId.delete(threadId);
970
1052
  baselineRecoveryStateByThreadId.delete(threadId);
1053
+ notifyActivityRemoval(threadId, "removed");
971
1054
  rejectHeldFollowerRequests(threadId, "This thread is no longer available for Desktop routing.");
972
1055
  }
973
1056
 
@@ -1247,6 +1330,24 @@ function createDesktopIpcActionFollower({
1247
1330
  )) {
1248
1331
  canonicalHistoryThreadIds.add(threadId);
1249
1332
  }
1333
+ // Resume acquires a writer in app-server. A metadata-only resume must stay
1334
+ // with the Desktop owner even when historical turns require canonical paging.
1335
+ // Returning no embedded turns also preserves the phone's pagination support.
1336
+ if (canonicalHistoryThreadIds.has(threadId)
1337
+ && method === "thread/resume"
1338
+ && message.params?.excludeTurns === true) {
1339
+ const thread = projectDesktopConversationStateToThread(
1340
+ threadId, boundedDesktopLiveStateForThread(threadId, rawState), { now }
1341
+ );
1342
+ sendApplicationResponse(JSON.stringify({
1343
+ id: message.id,
1344
+ result: {
1345
+ thread: { ...thread, turns: [] },
1346
+ remodexDesktopIpcMirror: true,
1347
+ },
1348
+ }));
1349
+ return true;
1350
+ }
1250
1351
  if (canonicalHistoryThreadIds.has(threadId)) {
1251
1352
  if (isThreadTurnStateProbeRequest(message)) {
1252
1353
  const liveState = boundedDesktopLiveStateForThread(threadId, rawState);
@@ -1256,9 +1357,8 @@ function createDesktopIpcActionFollower({
1256
1357
  }));
1257
1358
  return true;
1258
1359
  }
1259
- // Falling through only starts a canonical request. It may be a metadata-
1260
- // only resume or may fail before history arrives, so keep the repair
1261
- // signal armed until a live update can force a verified reload.
1360
+ // Keep the repair signal armed until canonical history actually arrives;
1361
+ // starting a request alone does not prove that recovery succeeded.
1262
1362
  return ownsDesktopCursor ? rejectDesktopTurnsCursor(message) : false;
1263
1363
  }
1264
1364
  const thread = projectDesktopConversationStateToThread(threadId, rawState, { now });
@@ -1377,6 +1477,33 @@ function createDesktopIpcActionFollower({
1377
1477
  );
1378
1478
  }
1379
1479
 
1480
+ function notifyActivityState(threadId, state) {
1481
+ if (typeof onActivityObservation !== "function") {
1482
+ return;
1483
+ }
1484
+ const index = normalizedLiveIndexesByThreadId.get(threadId);
1485
+ const turns = index
1486
+ ? boundedIndexedDesktopLiveTurns(
1487
+ state, index, now(), projectedLiveActiveTurnIdsByThreadId.get(threadId) || new Set(), true
1488
+ )
1489
+ : (Array.isArray(state?.turns) ? state.turns.slice(-3) : []);
1490
+ onActivityObservation({
1491
+ type: "state",
1492
+ threadId,
1493
+ sourceGeneration: Math.max(1, desktopSourceGeneration),
1494
+ state: boundedDesktopActivityState({ ...state, turns }),
1495
+ });
1496
+ }
1497
+
1498
+ function notifyActivityRemoval(threadId, reason) {
1499
+ onActivityObservation?.({
1500
+ type: "removed",
1501
+ threadId,
1502
+ reason,
1503
+ sourceGeneration: Math.max(1, desktopSourceGeneration),
1504
+ });
1505
+ }
1506
+
1380
1507
  function rememberDesktopLiveProjection(threadId, liveState) {
1381
1508
  const activeTurns = activeDesktopTurnDescriptors(liveState);
1382
1509
  const activeTurnIds = new Set(activeTurns.map((turn) => turn.id));
@@ -1792,6 +1919,7 @@ function createDesktopIpcActionFollower({
1792
1919
  normalizedReviewFingerprintsByThreadId.delete(threadId);
1793
1920
  conversationProjector.remove(threadId);
1794
1921
  syncProjectedActions(threadId, []);
1922
+ notifyActivityRemoval(threadId, "archived");
1795
1923
  }
1796
1924
  sendApplicationResponse(JSON.stringify({
1797
1925
  method: envelope.method === "thread-archived" ? "thread/archived" : "thread/unarchived",
@@ -1861,6 +1989,14 @@ function createDesktopIpcActionFollower({
1861
1989
  return null;
1862
1990
  }
1863
1991
 
1992
+ if (method === "thread/settings/update") {
1993
+ const threadSettings = normalizeThreadSettingsUpdate(params);
1994
+ return {
1995
+ threadId,
1996
+ method: "thread-follower-update-thread-settings",
1997
+ params: { conversationId: threadId, threadSettings },
1998
+ };
1999
+ }
1864
2000
  if (method === "turn/start") {
1865
2001
  return {
1866
2002
  threadId,
@@ -1877,6 +2013,7 @@ function createDesktopIpcActionFollower({
1877
2013
  params: {
1878
2014
  conversationId: threadId,
1879
2015
  input: Array.isArray(params.input) ? params.input : [],
2016
+ ...Object.fromEntries(["clientUserMessageId", "additionalContext", "toolOutput"].filter((key) => hasOwn(params, key)).map((key) => [key, cloneJSON(params[key])])),
1880
2017
  expectedTurnId: readString(params.expectedTurnId) || readString(params.expected_turn_id),
1881
2018
  },
1882
2019
  };
@@ -1906,27 +2043,36 @@ function createDesktopIpcActionFollower({
1906
2043
  }
1907
2044
 
1908
2045
  function submitDesktopFollowerRequest(route, originalMessage) {
1909
- Promise.resolve()
1910
- .then(() => resolveFollowerRequest(route))
1911
- .then(async (resolvedRequest) => {
1912
- if (route.method === "thread-follower-start-turn") {
1913
- try {
1914
- await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedRequest.turnStartParams);
1915
- } catch (error) {
1916
- // The actual turn has not reached Desktop yet. Even if the settings
1917
- // request timed out after being applied, continuing through the local
1918
- // app-server is safe because there is no Desktop turn to duplicate.
1919
- throw markDeliveryFailureError(error);
1920
- }
2046
+ enqueueMutation(route.threadId, async () => {
2047
+ const revisionBefore = runtimeSettingsStore?.get?.(route.threadId)?.revision;
2048
+ const resolvedRequest = await resolveFollowerRequest(route);
2049
+ if (route.method === "thread-follower-start-turn") {
2050
+ // A rejected settings update does not relinquish the Desktop writer.
2051
+ // Propagate it without turning a timeout into local delivery failure.
2052
+ try {
2053
+ await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedRequest.turnStartParams);
2054
+ } catch (error) {
2055
+ // Failure to deliver settings does not prove that a turn sent locally
2056
+ // would be safe. Only the start-turn route can authorize that fallback.
2057
+ throw new Error(error.message, { cause: error });
1921
2058
  }
1922
- return {
1923
- resolvedRequest,
1924
- result: await ipc.sendRequest(route.method, resolvedRequest.params),
1925
- };
1926
- })
1927
- .then(({ resolvedRequest, result }) => {
1928
- const appServerResult = appServerResultForFollowerRequest(route.method, result);
1929
- if (route.method === "thread-follower-start-turn") {
2059
+ }
2060
+ return {
2061
+ resolvedRequest,
2062
+ revisionBefore,
2063
+ result: await ipc.sendRequest(route.method, resolvedRequest.params),
2064
+ };
2065
+ })
2066
+ .then(({ resolvedRequest, revisionBefore, result }) => {
2067
+ const currentSettings = runtimeSettingsStore?.get?.(route.threadId);
2068
+ const receivedOwnerSettings = currentSettings && currentSettings.revision !== revisionBefore;
2069
+ let appServerResult = appServerResultForFollowerRequest(route.method, result);
2070
+ if (route.method === "thread-follower-update-thread-settings") {
2071
+ const settings = receivedOwnerSettings ? currentSettings
2072
+ : runtimeSettingsStore?.commit?.(route.threadId, route.params.threadSettings, { source: "phone" });
2073
+ appServerResult = { runtimeSettings: settings || null };
2074
+ }
2075
+ if (route.method === "thread-follower-start-turn" && !receivedOwnerSettings) {
1930
2076
  commitPhoneRuntimeSettings(
1931
2077
  route.threadId,
1932
2078
  resolvedRequest.turnStartParams,
@@ -1962,7 +2108,7 @@ function createDesktopIpcActionFollower({
1962
2108
  }
1963
2109
 
1964
2110
  function appServerResultForFollowerRequest(method, result) {
1965
- if (method === "thread-follower-start-turn"
2111
+ if ((method === "thread-follower-start-turn" || method === "thread-follower-steer-turn")
1966
2112
  && result
1967
2113
  && typeof result === "object"
1968
2114
  && !Array.isArray(result)
@@ -1987,28 +2133,17 @@ function createDesktopIpcActionFollower({
1987
2133
  const params = turnStartParams && typeof turnStartParams === "object"
1988
2134
  ? turnStartParams
1989
2135
  : {};
1990
- const collaborationMode = params.collaborationMode && typeof params.collaborationMode === "object"
1991
- ? cloneJSON(params.collaborationMode)
1992
- : null;
1993
- const collaborationSettings = collaborationMode?.settings;
1994
- const model = readString(params.model) || readString(collaborationSettings?.model);
1995
- const effort = readString(params.effort)
1996
- || readString(params.reasoningEffort)
1997
- || readString(collaborationSettings?.reasoning_effort)
1998
- || readString(collaborationSettings?.reasoningEffort);
1999
- if (!model && !effort && !collaborationMode) {
2000
- return;
2001
- }
2002
-
2136
+ const patch = runtimeSettingsPatch(params);
2137
+ const threadSettings = {
2138
+ ...(patch.model ? { model: patch.model } : {}),
2139
+ ...(hasOwn(patch, "reasoningEffort") ? { effort: patch.reasoningEffort } : {}),
2140
+ ...(hasOwn(patch, "serviceTier") ? { serviceTier: patch.serviceTier } : {}),
2141
+ ...(params.collaborationMode ? { collaborationMode: cloneJSON(params.collaborationMode) } : {}),
2142
+ };
2143
+ if (Object.keys(threadSettings).length === 0) return;
2003
2144
  await ipc.sendRequest("thread-follower-update-thread-settings", {
2004
2145
  conversationId: threadId,
2005
- threadSettings: {
2006
- ...(model ? { model } : {}),
2007
- effort: effort || null,
2008
- // turn/start omission is the app-server representation of Normal speed.
2009
- serviceTier: readString(params.serviceTier) || readString(params.service_tier) || null,
2010
- ...(collaborationMode ? { collaborationMode } : {}),
2011
- },
2146
+ threadSettings,
2012
2147
  });
2013
2148
  }
2014
2149
 
@@ -2135,6 +2270,7 @@ function createDesktopIpcActionFollower({
2135
2270
  rawStatesByThreadId.set(threadId, nextState);
2136
2271
  rawStateUpdatedAtByThreadId.set(threadId, now());
2137
2272
  rebuildNormalizedLiveIndex(threadId, nextState);
2273
+ notifyActivityState(threadId, nextState);
2138
2274
  if (baselineState && typeof baselineState === "object"
2139
2275
  && !backgroundOnlyThreadIds.has(threadId)) {
2140
2276
  const liveState = boundedDesktopLiveState(
@@ -2157,6 +2293,7 @@ function createDesktopIpcActionFollower({
2157
2293
 
2158
2294
  return {
2159
2295
  observeInbound,
2296
+ observeThreadListResponse,
2160
2297
  stopAll,
2161
2298
  // True while this thread has live Desktop-owned IPC state mirrored to the
2162
2299
  // phone; used to keep fallback mirrors (rollout tail) silent.
@@ -2188,7 +2325,7 @@ function createDesktopIpcActionFollower({
2188
2325
  // has actually moved recently. Keep hasLiveThreadState's broader meaning
2189
2326
  // for callers that need cached/idle Desktop state, but expose this explicit
2190
2327
  // lease check for source arbitration.
2191
- hasFreshLiveThreadState(threadId, { fallbackActivityAt = 0 } = {}) {
2328
+ hasFreshLiveThreadState(threadId, { fallbackActivityAt = 0, probeFallbackActivity = false } = {}) {
2192
2329
  const normalizedThreadId = readString(threadId);
2193
2330
  if (pendingSnapshotsByThreadId.has(normalizedThreadId)) {
2194
2331
  return Boolean(normalizedThreadId);
@@ -2209,8 +2346,11 @@ function createDesktopIpcActionFollower({
2209
2346
  if (hasActiveProjectedTurn(thread)) {
2210
2347
  const updatedAt = rawStateUpdatedAtByThreadId.get(normalizedThreadId) || 0;
2211
2348
  const hasNewerFallbackActivity = Number(fallbackActivityAt) > updatedAt;
2349
+ // Let a stale stream check file metadata before deciding who emits.
2350
+ // The subsequent check with the real mtime still protects quiet work.
2212
2351
  return hasResponsiveDesktopIpc()
2213
- && (!isRawStateStaleForActiveRead(normalizedThreadId) || !hasNewerFallbackActivity);
2352
+ && (!isRawStateStaleForActiveRead(normalizedThreadId)
2353
+ || (!probeFallbackActivity && !hasNewerFallbackActivity));
2214
2354
  }
2215
2355
  return !isRawStateStaleForActiveRead(normalizedThreadId);
2216
2356
  },
@@ -2516,6 +2656,22 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2516
2656
  return null;
2517
2657
  }
2518
2658
 
2659
+ if (route.method === "item/permissions/requestApproval") {
2660
+ const result = responseMessage?.result;
2661
+ if (!result?.permissions || typeof result.permissions !== "object"
2662
+ || Array.isArray(result.permissions) || !["turn", "session"].includes(result.scope)) {
2663
+ return null;
2664
+ }
2665
+ return {
2666
+ method,
2667
+ params: {
2668
+ conversationId: route.threadId,
2669
+ requestId: route.desktopRequestId ?? route.requestId,
2670
+ response: cloneJSON(result),
2671
+ },
2672
+ };
2673
+ }
2674
+
2519
2675
  if (route.method === "item/tool/requestUserInput") {
2520
2676
  const answers = responseMessage?.result?.answers;
2521
2677
  if (!answers || typeof answers !== "object" || Array.isArray(answers)) {
@@ -2534,7 +2690,7 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2534
2690
  };
2535
2691
  }
2536
2692
 
2537
- const decision = desktopApprovalDecisionForResponse(route.method, responseMessage?.result);
2693
+ const decision = readString(responseMessage?.result?.decision);
2538
2694
  if (!APPROVAL_DECISIONS.has(decision)) {
2539
2695
  return null;
2540
2696
  }
@@ -2543,53 +2699,12 @@ function desktopFollowerPayloadForResponse(route, responseMessage) {
2543
2699
  method,
2544
2700
  params: {
2545
2701
  conversationId: route.threadId,
2546
- requestId: route.requestId,
2702
+ requestId: route.desktopRequestId ?? route.requestId,
2547
2703
  decision,
2548
2704
  },
2549
2705
  };
2550
2706
  }
2551
2707
 
2552
- function desktopApprovalDecisionForResponse(method, result) {
2553
- const explicitDecision = readString(result?.decision);
2554
- if (explicitDecision) {
2555
- return explicitDecision;
2556
- }
2557
-
2558
- if (method !== "item/permissions/requestApproval") {
2559
- return "";
2560
- }
2561
-
2562
- // Permission approvals use a grant payload on app-server, while Desktop IPC
2563
- // currently exposes only decision-style follower replies.
2564
- return hasGrantedPermission(result?.permissions) ? "accept" : "decline";
2565
- }
2566
-
2567
- function hasGrantedPermission(value) {
2568
- if (!value || typeof value !== "object" || Array.isArray(value)) {
2569
- return false;
2570
- }
2571
-
2572
- if (Object.keys(value).length === 0) {
2573
- return false;
2574
- }
2575
-
2576
- return Object.values(value).some((entry) => {
2577
- if (entry == null) {
2578
- return false;
2579
- }
2580
- if (typeof entry === "boolean") {
2581
- return entry;
2582
- }
2583
- if (Array.isArray(entry)) {
2584
- return entry.length > 0;
2585
- }
2586
- if (typeof entry === "object") {
2587
- return Object.keys(entry).length > 0;
2588
- }
2589
- return true;
2590
- });
2591
- }
2592
-
2593
2708
  function projectPendingDesktopActions(threadId, conversationState) {
2594
2709
  const requests = Array.isArray(conversationState?.requests) ? conversationState.requests : [];
2595
2710
  return requests
@@ -3014,6 +3129,84 @@ function boundedDesktopLiveState(
3014
3129
  };
3015
3130
  }
3016
3131
 
3132
+ function boundedDesktopActivityState(state) {
3133
+ const rawState = state && typeof state === "object" ? state : {};
3134
+ return {
3135
+ title: readString(rawState.title) || readString(rawState.name),
3136
+ cwd: readString(rawState.cwd) || readString(rawState.current_working_directory),
3137
+ threadRuntimeStatus: compactDesktopRuntimeStatus(rawState.threadRuntimeStatus),
3138
+ status: compactDesktopRuntimeStatus(rawState.status),
3139
+ hasUnreadTurn: rawState.hasUnreadTurn ?? rawState.has_unread_turn,
3140
+ unreadMessageCount: rawState.unreadMessageCount ?? rawState.unread_message_count,
3141
+ requests: (Array.isArray(rawState.requests) ? rawState.requests : []).map((request) => ({
3142
+ id: request?.id,
3143
+ method: readString(request?.method),
3144
+ completed: request?.completed === true,
3145
+ })),
3146
+ turns: (Array.isArray(rawState.turns) ? rawState.turns : []).map(compactDesktopActivityTurn),
3147
+ };
3148
+ }
3149
+
3150
+ function compactDesktopRuntimeStatus(status) {
3151
+ if (status && typeof status === "object" && !Array.isArray(status)) {
3152
+ return {
3153
+ type: readString(status.type),
3154
+ activeFlags: Array.isArray(status.activeFlags) ? status.activeFlags.filter((flag) => (
3155
+ flag === "waitingOnApproval" || flag === "waitingOnUserInput"
3156
+ )) : [],
3157
+ };
3158
+ }
3159
+ return readString(status);
3160
+ }
3161
+
3162
+ function compactDesktopActivityTurn(turn) {
3163
+ return {
3164
+ id: readString(turn?.id),
3165
+ turnId: readString(turn?.turnId),
3166
+ turn_id: readString(turn?.turn_id),
3167
+ status: readString(turn?.status),
3168
+ error: turn?.error ? true : null,
3169
+ startedAt: finiteTimestamp(turn?.startedAt),
3170
+ started_at: finiteTimestamp(turn?.started_at),
3171
+ completedAt: finiteTimestamp(turn?.completedAt),
3172
+ completed_at: finiteTimestamp(turn?.completed_at),
3173
+ turnStartedAtMs: finiteTimestamp(turn?.turnStartedAtMs),
3174
+ turn_started_at_ms: finiteTimestamp(turn?.turn_started_at_ms),
3175
+ turnCompletedAtMs: finiteTimestamp(turn?.turnCompletedAtMs),
3176
+ turn_completed_at_ms: finiteTimestamp(turn?.turn_completed_at_ms),
3177
+ startedAtMs: finiteTimestamp(turn?.startedAtMs ?? turn?.started_at_ms),
3178
+ completedAtMs: finiteTimestamp(turn?.completedAtMs ?? turn?.completed_at_ms),
3179
+ items: compactLatestActivityItems(turn),
3180
+ };
3181
+ }
3182
+
3183
+ function compactLatestActivityItems(turn) {
3184
+ const items = Array.isArray(turn?.items) ? turn.items : [];
3185
+ // Token patches must not copy the growing item history. A small tail is
3186
+ // enough for a current semantic label; absence remains unknown.
3187
+ for (let index = items.length - 1; index >= Math.max(0, items.length - 32); index -= 1) {
3188
+ const item = items[index];
3189
+ if (!projectSemanticItem(item)) {
3190
+ continue;
3191
+ }
3192
+ return [{
3193
+ id: readString(item?.id),
3194
+ itemId: readString(item?.itemId),
3195
+ item_id: readString(item?.item_id),
3196
+ type: readString(item?.type),
3197
+ }];
3198
+ }
3199
+ return [];
3200
+ }
3201
+
3202
+ function finiteTimestamp(value) {
3203
+ if (value == null || value === "") {
3204
+ return null;
3205
+ }
3206
+ const number = Number(value);
3207
+ return Number.isFinite(number) ? number : null;
3208
+ }
3209
+
3017
3210
  function boundedDesktopLiveTurns(
3018
3211
  state,
3019
3212
  nowValue = Date.now(),
@@ -3063,7 +3256,7 @@ function boundedDesktopLiveTurns(
3063
3256
  return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
3064
3257
  }
3065
3258
 
3066
- function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds) {
3259
+ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds, canonicalActivity = false) {
3067
3260
  if (index.entries.length === 0) {
3068
3261
  return [];
3069
3262
  }
@@ -3104,10 +3297,11 @@ function boundedIndexedDesktopLiveTurns(state, index, nowValue, retainedTurnIds)
3104
3297
  if (!turn) {
3105
3298
  return null;
3106
3299
  }
3107
- return turnIdOf(turn) ? turn : { ...turn, id: entry.id };
3300
+ return turnIdOf(turn) || (canonicalActivity && entry.rawIndex != null)
3301
+ ? turn : { ...turn, id: entry.id };
3108
3302
  })
3109
3303
  .filter(Boolean);
3110
- return normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
3304
+ return canonicalActivity ? selectedTurns : normalizeBoundedTurnsForRuntime(selectedTurns, state, retainedTurnIds);
3111
3305
  }
3112
3306
 
3113
3307
  function resolveIndexedTurn(state, entry) {