@makerbi/remodex 2.5.6 → 3.2.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.5.6",
3
+ "version": "3.2.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",
@@ -443,7 +443,8 @@ function createDesktopIpcActionFollower({
443
443
  // stays idle after opening never delivers them.
444
444
  emitNormalizedReviewOverlays(
445
445
  threadId,
446
- normalizedLiveIndexesByThreadId.get(threadId)
446
+ normalizedLiveIndexesByThreadId.get(threadId),
447
+ liveState
447
448
  );
448
449
  const output = conversationProjector.project(threadId, liveState, {
449
450
  includeAllActiveTurns: true,
@@ -1516,7 +1517,8 @@ function createDesktopIpcActionFollower({
1516
1517
  }));
1517
1518
  emitNormalizedReviewOverlays(
1518
1519
  threadId,
1519
- normalizedLiveIndexesByThreadId.get(threadId)
1520
+ normalizedLiveIndexesByThreadId.get(threadId),
1521
+ liveState
1520
1522
  );
1521
1523
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1522
1524
  for (const notification of bootstrapOutput.notifications || []) {
@@ -1535,7 +1537,8 @@ function createDesktopIpcActionFollower({
1535
1537
  emitDesktopSnapshotLifecycleTransition(threadId, liveState);
1536
1538
  emitNormalizedReviewOverlays(
1537
1539
  threadId,
1538
- normalizedLiveIndexesByThreadId.get(threadId)
1540
+ normalizedLiveIndexesByThreadId.get(threadId),
1541
+ liveState
1539
1542
  );
1540
1543
  conversationProjector.seed(threadId, liveState);
1541
1544
  rememberDesktopLiveProjection(threadId, liveState);
@@ -1548,7 +1551,8 @@ function createDesktopIpcActionFollower({
1548
1551
  }
1549
1552
  emitNormalizedReviewOverlays(
1550
1553
  threadId,
1551
- normalizedLiveIndexesByThreadId.get(threadId)
1554
+ normalizedLiveIndexesByThreadId.get(threadId),
1555
+ liveState
1552
1556
  );
1553
1557
  const output = conversationProjector.project(threadId, liveState);
1554
1558
  if (resumedAfterStaleYield || output.type === "fullReplace" || output.type === "baseline") {
@@ -1605,11 +1609,16 @@ function createDesktopIpcActionFollower({
1605
1609
  rememberDesktopLiveProjection(threadId, liveState);
1606
1610
  }
1607
1611
 
1608
- function emitNormalizedReviewOverlays(threadId, index) {
1612
+ function emitNormalizedReviewOverlays(threadId, index, projectedState) {
1609
1613
  if (!index || !Array.isArray(index.pendingReviewOverlays)) {
1610
1614
  return;
1611
1615
  }
1612
1616
  const overlays = index.pendingReviewOverlays.splice(0);
1617
+ const projectedTurnIds = new Set(
1618
+ (Array.isArray(projectedState?.turns) ? projectedState.turns : [])
1619
+ .map(turnIdOf)
1620
+ .filter(Boolean)
1621
+ );
1613
1622
  const fingerprints = normalizedReviewFingerprintsByThreadId.get(threadId) || new Map();
1614
1623
  for (const overlay of overlays) {
1615
1624
  // Reviews on turns in the projected live tail may also be emitted by
@@ -1626,6 +1635,16 @@ function createDesktopIpcActionFollower({
1626
1635
  if (!reviewId || !status || !item?.action) {
1627
1636
  continue;
1628
1637
  }
1638
+ // Completed approved reviews are ordinary tool history. Replaying every
1639
+ // normalized-history review after a bounded canonical read appends old
1640
+ // rows at the phone's live tail, detached from their original disclosure.
1641
+ // The projected tail still emits its approvals so they reconcile in the
1642
+ // owning turn; non-approved outcomes remain global because they may need
1643
+ // the user's attention.
1644
+ if (normalizeToken(status) === "approved"
1645
+ && !projectedTurnIds.has(readString(overlay.turnId))) {
1646
+ continue;
1647
+ }
1629
1648
  const fingerprint = createHash("sha256")
1630
1649
  .update(JSON.stringify({ status, item }))
1631
1650
  .digest("hex");
@@ -1846,11 +1865,9 @@ function createDesktopIpcActionFollower({
1846
1865
  return {
1847
1866
  threadId,
1848
1867
  method: "thread-follower-start-turn",
1849
- params: {
1850
- conversationId: threadId,
1851
- senderRequestId: requestId,
1852
- turnStartParams: params,
1853
- },
1868
+ params: { conversationId: threadId },
1869
+ senderRequestId: requestId,
1870
+ turnStartParams: params,
1854
1871
  };
1855
1872
  }
1856
1873
  if (method === "turn/steer") {
@@ -1870,7 +1887,8 @@ function createDesktopIpcActionFollower({
1870
1887
  method: "thread-follower-interrupt-turn",
1871
1888
  params: {
1872
1889
  conversationId: threadId,
1873
- turnId: readString(params.turnId) || readString(params.turn_id),
1890
+ mode: "user-stop",
1891
+ expectedTurnId: readString(params.turnId) || readString(params.turn_id),
1874
1892
  },
1875
1893
  };
1876
1894
  }
@@ -1889,11 +1907,11 @@ function createDesktopIpcActionFollower({
1889
1907
 
1890
1908
  function submitDesktopFollowerRequest(route, originalMessage) {
1891
1909
  Promise.resolve()
1892
- .then(() => resolveFollowerRequestParams(route))
1893
- .then(async (resolvedParams) => {
1910
+ .then(() => resolveFollowerRequest(route))
1911
+ .then(async (resolvedRequest) => {
1894
1912
  if (route.method === "thread-follower-start-turn") {
1895
1913
  try {
1896
- await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedParams.turnStartParams);
1914
+ await syncDesktopOwnerRuntimeSettings(route.threadId, resolvedRequest.turnStartParams);
1897
1915
  } catch (error) {
1898
1916
  // The actual turn has not reached Desktop yet. Even if the settings
1899
1917
  // request timed out after being applied, continuing through the local
@@ -1902,16 +1920,16 @@ function createDesktopIpcActionFollower({
1902
1920
  }
1903
1921
  }
1904
1922
  return {
1905
- resolvedParams,
1906
- result: await ipc.sendRequest(route.method, resolvedParams),
1923
+ resolvedRequest,
1924
+ result: await ipc.sendRequest(route.method, resolvedRequest.params),
1907
1925
  };
1908
1926
  })
1909
- .then(({ resolvedParams, result }) => {
1927
+ .then(({ resolvedRequest, result }) => {
1910
1928
  const appServerResult = appServerResultForFollowerRequest(route.method, result);
1911
1929
  if (route.method === "thread-follower-start-turn") {
1912
1930
  commitPhoneRuntimeSettings(
1913
1931
  route.threadId,
1914
- resolvedParams.turnStartParams,
1932
+ resolvedRequest.turnStartParams,
1915
1933
  readTurnIdFromAppServerResult(appServerResult)
1916
1934
  );
1917
1935
  }
@@ -2007,19 +2025,34 @@ function createDesktopIpcActionFollower({
2007
2025
 
2008
2026
  // Desktop-followed turn starts must apply the same param normalization as
2009
2027
  // requests forwarded straight to the local app-server.
2010
- async function resolveFollowerRequestParams(route) {
2028
+ async function resolveFollowerRequest(route) {
2011
2029
  if (route.method !== "thread-follower-start-turn") {
2012
- return route.params;
2030
+ return {
2031
+ params: route.params,
2032
+ turnStartParams: null,
2033
+ };
2013
2034
  }
2014
2035
 
2015
2036
  const normalized = await Promise.resolve(
2016
- normalizeTurnStartParams(cloneJSON(route.params.turnStartParams))
2037
+ normalizeTurnStartParams(cloneJSON(route.turnStartParams))
2017
2038
  );
2018
2039
  const turnStartParams = normalized && typeof normalized === "object" && !Array.isArray(normalized)
2019
2040
  ? normalized
2020
- : route.params.turnStartParams;
2041
+ : route.turnStartParams;
2042
+ const request = cloneJSON(turnStartParams);
2043
+ if (!readString(request.clientUserMessageId)) {
2044
+ request.clientUserMessageId = route.senderRequestId;
2045
+ }
2021
2046
  return {
2022
- ...route.params,
2047
+ params: {
2048
+ ...route.params,
2049
+ turnStart: {
2050
+ request,
2051
+ context: {
2052
+ inheritThreadSettings: true,
2053
+ },
2054
+ },
2055
+ },
2023
2056
  turnStartParams,
2024
2057
  };
2025
2058
  }
@@ -454,14 +454,24 @@ function buildConversationStateFromThread(thread, {
454
454
  now,
455
455
  });
456
456
 
457
- return {
457
+ const state = {
458
458
  id: threadId,
459
+ forkedFromId: previous?.forkedFromId || null,
459
460
  hostId,
460
461
  turns,
461
462
  requests: cloneJSON(previous?.requests || []),
462
463
  createdAt: createdAtMs,
463
464
  updatedAt: updatedAtMs,
465
+ recencyAt: updatedAtMs,
464
466
  title: readString(thread?.name) || previous?.title || null,
467
+ source: readString(thread?.source) || previous?.source || "vscode",
468
+ agentNickname: previous?.agentNickname || null,
469
+ threadSource: readString(thread?.threadSource) || previous?.threadSource || "user",
470
+ historyMode: previous?.historyMode || "legacy",
471
+ parentThreadId: previous?.parentThreadId || null,
472
+ mode: previous?.mode || "default",
473
+ threadStartKind: previous?.threadStartKind || "default",
474
+ modelProvider: readString(thread?.modelProvider) || previous?.modelProvider || "openai",
465
475
  latestModel,
466
476
  latestReasoningEffort: previous?.latestReasoningEffort || null,
467
477
  latestServiceTier: previous?.latestServiceTier || null,
@@ -488,7 +498,10 @@ function buildConversationStateFromThread(thread, {
488
498
  workspaceBrowserRoot: previous?.workspaceBrowserRoot || null,
489
499
  projectlessOutputDirectory: previous?.projectlessOutputDirectory || null,
490
500
  currentPermissions: cloneJSON(previous?.currentPermissions || null),
501
+ sessionId: readString(thread?.sessionId) || previous?.sessionId || null,
491
502
  };
503
+ synchronizeDesktopConversationCompatibility(state);
504
+ return state;
492
505
  }
493
506
 
494
507
  function mergeConversationTurnsFromThread(threadTurns, {
@@ -552,7 +565,7 @@ function createEmptyConversationState(threadId, {
552
565
  cwd = "",
553
566
  } = {}) {
554
567
  const timestamp = now();
555
- return {
568
+ const state = {
556
569
  id: threadId,
557
570
  hostId,
558
571
  turns: [],
@@ -587,6 +600,189 @@ function createEmptyConversationState(threadId, {
587
600
  projectlessOutputDirectory: null,
588
601
  currentPermissions: null,
589
602
  };
603
+ synchronizeDesktopConversationCompatibility(state);
604
+ return state;
605
+ }
606
+
607
+ // Desktop 26.825 moved rendered history to a canonical entity graph. It still
608
+ // accepts legacy turns during normalization, but unified-timeline selectors now
609
+ // assume the graph and several nested collections exist. Keep the bridge's
610
+ // mutable legacy turns for app-server event handling, and mirror them into the
611
+ // current Desktop shape before every stream broadcast.
612
+ function synchronizeDesktopConversationCompatibility(state) {
613
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
614
+ return state;
615
+ }
616
+
617
+ const turns = Array.isArray(state.turns) ? state.turns : [];
618
+ const entries = [];
619
+ const entitiesByKey = {};
620
+ for (const turn of turns) {
621
+ const turnId = readString(turn?.turnId) || readString(turn?.id);
622
+ if (!turnId) {
623
+ continue;
624
+ }
625
+ turn.params = normalizeTurnParamsCompatibility(turn.params, {
626
+ cwd: readString(turn.params?.cwd) || readString(state.cwd),
627
+ });
628
+ turn.items = Array.isArray(turn.items) ? turn.items : [];
629
+ turn.hookRuns = Array.isArray(turn.hookRuns) ? turn.hookRuns : [];
630
+ const key = `turn:${turnId}`;
631
+ entries.push({ key, value: key });
632
+ const { id: _legacyId, ...canonicalTurn } = turn;
633
+ entitiesByKey[key] = {
634
+ ...canonicalTurn,
635
+ turnId,
636
+ };
637
+ }
638
+
639
+ const islandId = "tail:0";
640
+ state.turnHistory = {
641
+ kind: "canonical",
642
+ history: {
643
+ entitiesByKey,
644
+ generation: 0,
645
+ isComplete: true,
646
+ islands: [{
647
+ id: islandId,
648
+ entries,
649
+ olderBoundary: {
650
+ status: "exhausted",
651
+ boundaryId: `${islandId}:older`,
652
+ },
653
+ newerBoundary: {
654
+ status: "exhausted",
655
+ boundaryId: `${islandId}:newer`,
656
+ },
657
+ }],
658
+ },
659
+ };
660
+ state.turnsPagination = {
661
+ olderCursor: null,
662
+ oldestLoadedTurnId: readString(turns[0]?.turnId) || readString(turns[0]?.id) || null,
663
+ isLoadingOlder: false,
664
+ hasLoadedOldest: true,
665
+ };
666
+
667
+ const latestParams = [...turns]
668
+ .reverse()
669
+ .map((turn) => turn?.params)
670
+ .find((params) => params && typeof params === "object") || null;
671
+ const sandboxPolicy = normalizeSandboxPolicyCompatibility(
672
+ latestParams?.sandboxPolicy || state.currentPermissions?.sandboxPolicy
673
+ );
674
+ if (sandboxPolicy) {
675
+ const runtimeWorkspaceRoots = Array.isArray(latestParams?.runtimeWorkspaceRoots)
676
+ ? cloneJSON(latestParams.runtimeWorkspaceRoots)
677
+ : Array.isArray(state.currentPermissions?.runtimeWorkspaceRoots)
678
+ ? cloneJSON(state.currentPermissions.runtimeWorkspaceRoots)
679
+ : [];
680
+ state.currentPermissions = {
681
+ activePermissionProfile: cloneJSON(
682
+ latestParams?.activePermissionProfile
683
+ ?? state.currentPermissions?.activePermissionProfile
684
+ ?? permissionProfileFromId(latestParams?.permissions)
685
+ ),
686
+ approvalPolicy: latestParams?.approvalPolicy
687
+ ?? state.currentPermissions?.approvalPolicy
688
+ ?? "on-request",
689
+ approvalsReviewer: latestParams?.approvalsReviewer
690
+ ?? state.currentPermissions?.approvalsReviewer
691
+ ?? "user",
692
+ runtimeWorkspaceRoots,
693
+ sandboxPolicy,
694
+ };
695
+ }
696
+
697
+ state.recencyAt = Number.isFinite(state.recencyAt) ? state.recencyAt : state.updatedAt;
698
+ state.source ||= "vscode";
699
+ state.threadSource ||= "user";
700
+ state.historyMode ||= "legacy";
701
+ state.mode ||= "default";
702
+ state.threadStartKind ||= "default";
703
+ state.modelProvider ||= "openai";
704
+ state.latestThreadSettings = {
705
+ cwd: readString(latestParams?.cwd) || readString(state.cwd) || null,
706
+ approvalPolicy: latestParams?.approvalPolicy ?? state.currentPermissions?.approvalPolicy ?? null,
707
+ approvalsReviewer: latestParams?.approvalsReviewer
708
+ ?? state.currentPermissions?.approvalsReviewer
709
+ ?? null,
710
+ ...(sandboxPolicy ? { sandboxPolicy } : {}),
711
+ activePermissionProfile: cloneJSON(state.currentPermissions?.activePermissionProfile ?? null),
712
+ model: readString(state.latestThreadSettings?.model)
713
+ || readString(latestParams?.model)
714
+ || readString(state.latestModel)
715
+ || null,
716
+ modelProvider: readString(state.latestThreadSettings?.modelProvider)
717
+ || readString(state.modelProvider)
718
+ || "openai",
719
+ serviceTier: readString(state.latestThreadSettings?.serviceTier)
720
+ || readString(latestParams?.serviceTier)
721
+ || readString(state.latestServiceTier)
722
+ || null,
723
+ effort: state.latestThreadSettings?.effort
724
+ ?? latestParams?.effort
725
+ ?? state.latestReasoningEffort
726
+ ?? null,
727
+ summary: latestParams?.summary ?? state.latestThreadSettings?.summary ?? "none",
728
+ collaborationMode: cloneJSON(
729
+ state.latestThreadSettings?.collaborationMode
730
+ || latestParams?.collaborationMode
731
+ || state.latestCollaborationMode
732
+ || null
733
+ ),
734
+ multiAgentMode: latestParams?.multiAgentMode
735
+ ?? state.latestThreadSettings?.multiAgentMode
736
+ ?? null,
737
+ personality: latestParams?.personality
738
+ ?? state.latestThreadSettings?.personality
739
+ ?? null,
740
+ };
741
+ return state;
742
+ }
743
+
744
+ function normalizeTurnParamsCompatibility(params, { cwd = "" } = {}) {
745
+ const normalized = params && typeof params === "object" && !Array.isArray(params)
746
+ ? params
747
+ : {};
748
+ normalized.input = Array.isArray(normalized.input) ? normalized.input : [];
749
+ normalized.attachments = Array.isArray(normalized.attachments) ? normalized.attachments : [];
750
+ normalized.cwd = readString(normalized.cwd) || readString(cwd) || null;
751
+ normalized.summary ??= "none";
752
+ normalized.personality ??= null;
753
+ normalized.outputSchema ??= null;
754
+ normalized.collaborationMode ??= null;
755
+ const sandboxPolicy = normalizeSandboxPolicyCompatibility(normalized.sandboxPolicy);
756
+ if (sandboxPolicy) {
757
+ normalized.sandboxPolicy = sandboxPolicy;
758
+ }
759
+ return normalized;
760
+ }
761
+
762
+ function normalizeSandboxPolicyCompatibility(policy) {
763
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
764
+ return null;
765
+ }
766
+ const normalized = cloneJSON(policy);
767
+ if (normalizeToken(normalized.type) === "workspacewrite") {
768
+ normalized.type = "workspaceWrite";
769
+ normalized.writableRoots = Array.isArray(normalized.writableRoots)
770
+ ? normalized.writableRoots
771
+ : [];
772
+ normalized.excludeSlashTmp = Boolean(normalized.excludeSlashTmp);
773
+ normalized.excludeTmpdirEnvVar = Boolean(normalized.excludeTmpdirEnvVar);
774
+ normalized.networkAccess = Boolean(normalized.networkAccess);
775
+ }
776
+ if (normalizeToken(normalized.type) === "readonly") {
777
+ normalized.type = "readOnly";
778
+ normalized.networkAccess = Boolean(normalized.networkAccess);
779
+ }
780
+ return normalized;
781
+ }
782
+
783
+ function permissionProfileFromId(value) {
784
+ const id = readString(value);
785
+ return id ? { id, extends: null } : null;
590
786
  }
591
787
 
592
788
  function buildConversationTurn(turn, {
@@ -596,7 +792,7 @@ function buildConversationTurn(turn, {
596
792
  now = () => Date.now(),
597
793
  } = {}) {
598
794
  const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
599
- const params = cloneJSON(previousTurn?.params || {
795
+ const params = normalizeTurnParamsCompatibility(cloneJSON(previousTurn?.params || {
600
796
  threadId,
601
797
  input: [],
602
798
  cwd: cwd || null,
@@ -611,7 +807,7 @@ function buildConversationTurn(turn, {
611
807
  outputSchema: null,
612
808
  collaborationMode: null,
613
809
  attachments: [],
614
- });
810
+ }), { cwd });
615
811
  const builtTurn = {
616
812
  id: turnId,
617
813
  turnId,
@@ -678,6 +874,9 @@ function applyPendingTurnStartParams(
678
874
  ...turn.params,
679
875
  ...cloneJSON(pendingParams),
680
876
  };
877
+ turn.params = normalizeTurnParamsCompatibility(turn.params, {
878
+ cwd: readString(turn.params?.cwd) || readString(conversation?.cwd),
879
+ });
681
880
  normalizeTurnInitialPrompt(turn);
682
881
  }
683
882
 
@@ -1262,6 +1461,7 @@ module.exports = {
1262
1461
  readThreadIdFromParams,
1263
1462
  readTurnIdFromParams,
1264
1463
  readTurnIdFromTurn,
1464
+ synchronizeDesktopConversationCompatibility,
1265
1465
  timestampSecondsToMs,
1266
1466
  upsertItem,
1267
1467
  upsertTurn,
@@ -27,6 +27,7 @@ const {
27
27
  buildConversationStateFromThread,
28
28
  createEmptyConversationState,
29
29
  readThreadIdFromParams,
30
+ synchronizeDesktopConversationCompatibility,
30
31
  } = require("./desktop-ipc-conversation-adapter");
31
32
  const {
32
33
  DEFAULT_MAX_PATCH_BYTES,
@@ -94,20 +95,33 @@ const OWNER_INBOUND_METHODS = new Set([
94
95
 
95
96
  const THREAD_READ_METHODS = new Set(["thread/read", "thread/resume"]);
96
97
 
97
- const ALLOWED_TURN_START_PARAM_KEYS = new Set([
98
+ // Current app-server v2 TurnStartParams fields. Desktop's adjacent turnStart.context
99
+ // is presentation state, not part of this RPC shape, so it stays outside the request.
100
+ const APP_SERVER_TURN_START_PARAM_KEYS = new Set([
98
101
  "threadId",
99
102
  "input",
103
+ "additionalContext",
100
104
  "cwd",
101
105
  "approvalPolicy",
102
106
  "approvalsReviewer",
103
107
  "sandboxPolicy",
104
108
  "model",
105
109
  "serviceTier",
110
+ "serviceTierForTurn",
106
111
  "effort",
107
112
  "summary",
108
113
  "personality",
109
114
  "outputSchema",
110
115
  "collaborationMode",
116
+ "clientUserMessageId",
117
+ "cyberAccessProgram",
118
+ "environments",
119
+ "multiAgentMode",
120
+ "permissions",
121
+ "responsesapiClientMetadata",
122
+ "runtimeWorkspaceRoots",
123
+ "toolOutput",
124
+ "turnTrigger",
111
125
  ]);
112
126
 
113
127
  function createDesktopIpcLiveOwner({
@@ -1104,6 +1118,7 @@ function createDesktopIpcLiveOwner({
1104
1118
  return true;
1105
1119
  }
1106
1120
  runtimeSettingsStore?.attachToConversation?.(threadId, conversationState);
1121
+ synchronizeDesktopConversationCompatibility(conversationState);
1107
1122
  if (shouldDelayInitialSnapshotForHistory(threadId)) {
1108
1123
  return false;
1109
1124
  }
@@ -1367,10 +1382,7 @@ function createDesktopIpcLiveOwner({
1367
1382
  }
1368
1383
 
1369
1384
  async function handleFollowerStartTurn(conversationId, params) {
1370
- const rawTurnStartParams = params.turnStartParams
1371
- || params.turn_start_params
1372
- || params.turnStart
1373
- || params;
1385
+ const rawTurnStartParams = readFollowerTurnStartParams(params);
1374
1386
  const codexParams = mergeFollowerRuntimeOverrides(conversationId, sanitizeTurnStartParams({
1375
1387
  ...rawTurnStartParams,
1376
1388
  threadId: conversationId,
@@ -1380,7 +1392,9 @@ function createDesktopIpcLiveOwner({
1380
1392
  ? normalizedParams
1381
1393
  : codexParams;
1382
1394
  markOwnedThread(conversationId);
1383
- const senderRequestId = params.senderRequestId || params.sender_request_id;
1395
+ const senderRequestId = params.senderRequestId
1396
+ || params.sender_request_id
1397
+ || rawTurnStartParams.clientUserMessageId;
1384
1398
  const isKnownHeldPhoneStart = Boolean(
1385
1399
  requestIdKey(senderRequestId)
1386
1400
  && pendingTurnStartEntriesByRequestId.has(requestIdKey(senderRequestId))
@@ -1417,6 +1431,17 @@ function createDesktopIpcLiveOwner({
1417
1431
  }
1418
1432
  }
1419
1433
 
1434
+ function readFollowerTurnStartParams(params) {
1435
+ const turnStart = isPlainJSONObject(params.turnStart) ? params.turnStart : null;
1436
+ if (isPlainJSONObject(turnStart?.request)) {
1437
+ return turnStart.request;
1438
+ }
1439
+ return params.turnStartParams
1440
+ || params.turn_start_params
1441
+ || params.turnStart
1442
+ || params;
1443
+ }
1444
+
1420
1445
  function mirrorFollowerUserPromptToPhone(threadId, turnStartParams, turnStartResult = null) {
1421
1446
  const text = visibleUserPromptFromInputEntries(turnStartParams?.input);
1422
1447
  if (!text) {
@@ -1461,16 +1486,50 @@ function createDesktopIpcLiveOwner({
1461
1486
  }
1462
1487
 
1463
1488
  async function handleFollowerInterruptTurn(conversationId, params) {
1464
- const turnId = readString(params.turnId)
1465
- || readString(params.turn_id)
1466
- || activeTurnIdForConversation(conversationId);
1489
+ const requestedTurnId = readString(params.expectedTurnId)
1490
+ || readString(params.expected_turn_id)
1491
+ || readString(params.turnId)
1492
+ || readString(params.turn_id);
1493
+ const activeTurnId = activeTurnIdForConversation(conversationId);
1494
+ if (requestedTurnId && requestedTurnId !== activeTurnId) {
1495
+ return {
1496
+ interruptedTurnId: null,
1497
+ ok: true,
1498
+ };
1499
+ }
1500
+
1501
+ const goalPauseError = await pauseActiveGoalForUserStop(
1502
+ conversationId,
1503
+ params.mode,
1504
+ Boolean(requestedTurnId)
1505
+ );
1506
+ const turnId = requestedTurnId || activeTurnId;
1467
1507
  if (!turnId) {
1468
- throw new Error("Missing turnId for follower interrupt request.");
1508
+ return followerInterruptResult(null, goalPauseError);
1469
1509
  }
1470
- return await sendCodexRequest("turn/interrupt", {
1510
+ await sendCodexRequest("turn/interrupt", {
1471
1511
  threadId: conversationId,
1472
1512
  turnId,
1473
1513
  });
1514
+ return followerInterruptResult(turnId, goalPauseError);
1515
+ }
1516
+
1517
+ async function pauseActiveGoalForUserStop(conversationId, mode, hasRequestedTurnId) {
1518
+ if (mode !== "user-stop" || hasRequestedTurnId) {
1519
+ return "";
1520
+ }
1521
+ if (conversations.get(conversationId)?.threadGoal?.status !== "active") {
1522
+ return "";
1523
+ }
1524
+ try {
1525
+ await sendCodexRequest("thread/goal/set", {
1526
+ threadId: conversationId,
1527
+ status: "paused",
1528
+ });
1529
+ return "";
1530
+ } catch (error) {
1531
+ return error?.message || "Failed to pause thread goal.";
1532
+ }
1474
1533
  }
1475
1534
 
1476
1535
  // Desktop follower approvals only carry decision-style payloads, but app-server
@@ -1786,8 +1845,7 @@ function createDesktopIpcLiveOwner({
1786
1845
  return turnId;
1787
1846
  }
1788
1847
  }
1789
- const latestTurn = turns[turns.length - 1];
1790
- return readString(latestTurn?.turnId) || readString(latestTurn?.id);
1848
+ return "";
1791
1849
  }
1792
1850
 
1793
1851
  return {
@@ -1881,7 +1939,7 @@ function normalizeInputEntriesForDesktop(input) {
1881
1939
  function sanitizeTurnStartParams(params) {
1882
1940
  const sanitized = {};
1883
1941
  for (const [key, value] of Object.entries(params || {})) {
1884
- if (ALLOWED_TURN_START_PARAM_KEYS.has(key)) {
1942
+ if (APP_SERVER_TURN_START_PARAM_KEYS.has(key)) {
1885
1943
  sanitized[key] = value;
1886
1944
  }
1887
1945
  }
@@ -1891,6 +1949,14 @@ function sanitizeTurnStartParams(params) {
1891
1949
  return sanitized;
1892
1950
  }
1893
1951
 
1952
+ function followerInterruptResult(interruptedTurnId, goalPauseError) {
1953
+ return {
1954
+ interruptedTurnId,
1955
+ ...(goalPauseError ? { goalPauseError } : {}),
1956
+ ok: true,
1957
+ };
1958
+ }
1959
+
1894
1960
  function readThreadFromResponse(message) {
1895
1961
  const result = message?.result || message?.payload || {};
1896
1962
  return readThreadFromPayload(result);
@@ -28,12 +28,12 @@ const DESKTOP_IPC_METHOD_VERSIONS = new Map([
28
28
  ["thread-unarchived", 1],
29
29
  ["thread-read-state-changed", 2],
30
30
  ["thread-queued-followups-changed", 1],
31
- ["thread-follower-start-turn", 1],
31
+ ["thread-follower-start-turn", 2],
32
32
  ["thread-follower-load-complete-history", 1],
33
33
  ["thread-follower-update-thread-settings", 1],
34
34
  ["thread-follower-compact-thread", 1],
35
35
  ["thread-follower-steer-turn", 1],
36
- ["thread-follower-interrupt-turn", 3],
36
+ ["thread-follower-interrupt-turn", 4],
37
37
  ["thread-follower-set-model-and-reasoning", 1],
38
38
  ["thread-follower-set-collaboration-mode", 1],
39
39
  ["thread-follower-edit-last-user-turn", 2],
@@ -1476,6 +1476,8 @@ function toolStartNotifications(state, payload) {
1476
1476
  threadId: state.threadId,
1477
1477
  turnId: state.activeTurnId,
1478
1478
  call_id: callId,
1479
+ itemId: callId,
1480
+ status: "inProgress",
1479
1481
  message: activityMessage,
1480
1482
  }),
1481
1483
  ];
@@ -1536,6 +1538,10 @@ function customToolStartNotifications(state, payload) {
1536
1538
  threadId: state.threadId,
1537
1539
  turnId: state.activeTurnId,
1538
1540
  call_id: callId,
1541
+ ...(!state.applyPatchCalls.has(callId) ? {
1542
+ itemId: callId,
1543
+ status: "inProgress",
1544
+ } : {}),
1539
1545
  message: activityMessage,
1540
1546
  }),
1541
1547
  ];
@@ -1641,6 +1647,8 @@ function toolOutputNotifications(state, payload) {
1641
1647
  threadId: state.threadId,
1642
1648
  turnId: state.activeTurnId,
1643
1649
  call_id: callId,
1650
+ itemId: callId,
1651
+ status: "completed",
1644
1652
  message: genericToolCompletionMessage(toolCall.toolName),
1645
1653
  }));
1646
1654
  state.commandCalls.delete(callId);