@gkorepanov/ccodex 0.4.8 → 0.4.10

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.
Files changed (214) hide show
  1. package/README.md +1 -1
  2. package/config/compatibility.json +3 -3
  3. package/dist/claude/goalTools.js +1 -1
  4. package/dist/claude/goalTools.js.map +1 -1
  5. package/dist/claude/hookMapper.d.ts +1 -0
  6. package/dist/claude/hookMapper.js +1 -0
  7. package/dist/claude/hookMapper.js.map +1 -1
  8. package/dist/claude/inputMapper.js +4 -1
  9. package/dist/claude/inputMapper.js.map +1 -1
  10. package/dist/claude/modelCatalog.d.ts +1 -0
  11. package/dist/claude/modelCatalog.js +12 -9
  12. package/dist/claude/modelCatalog.js.map +1 -1
  13. package/dist/claude/rateLimits.d.ts +1 -0
  14. package/dist/claude/rateLimits.js +2 -1
  15. package/dist/claude/rateLimits.js.map +1 -1
  16. package/dist/claude/service.d.ts +13 -1
  17. package/dist/claude/service.js +152 -19
  18. package/dist/claude/service.js.map +1 -1
  19. package/dist/claude/session/commands.d.ts +3 -0
  20. package/dist/claude/session/goalState.js +9 -0
  21. package/dist/claude/session/goalState.js.map +1 -1
  22. package/dist/claude/session/repository.d.ts +1 -1
  23. package/dist/claude/session/repository.js +2 -2
  24. package/dist/claude/session/repository.js.map +1 -1
  25. package/dist/claude/session/scopeState.js +3 -1
  26. package/dist/claude/session/scopeState.js.map +1 -1
  27. package/dist/claude/session/session.js +13 -2
  28. package/dist/claude/session/session.js.map +1 -1
  29. package/dist/claude/session/usage.js +1 -0
  30. package/dist/claude/session/usage.js.map +1 -1
  31. package/dist/claude/toolMapper.js +2 -2
  32. package/dist/claude/toolMapper.js.map +1 -1
  33. package/dist/codex/generated/ClientRequest.d.ts +20 -0
  34. package/dist/codex/generated/CodexResponseHandoffMode.d.ts +1 -0
  35. package/dist/codex/generated/CodexResponseHandoffMode.js +3 -0
  36. package/dist/codex/generated/CodexResponseHandoffMode.js.map +1 -0
  37. package/dist/codex/generated/ContentItem.d.ts +3 -0
  38. package/dist/codex/generated/FunctionCallOutputContentItem.d.ts +3 -0
  39. package/dist/codex/generated/InputModality.d.ts +1 -1
  40. package/dist/codex/generated/LegacyAppPathString.d.ts +7 -6
  41. package/dist/codex/generated/RealtimeConversationVersion.d.ts +1 -1
  42. package/dist/codex/generated/ResponseItem.d.ts +15 -14
  43. package/dist/codex/generated/ResponseItemId.d.ts +5 -0
  44. package/dist/codex/generated/ResponseItemId.js +3 -0
  45. package/dist/codex/generated/ResponseItemId.js.map +1 -0
  46. package/dist/codex/generated/ResponseItemRuntimeSchema.js +2 -2
  47. package/dist/codex/generated/ResponseItemRuntimeSchema.js.map +1 -1
  48. package/dist/codex/generated/ReviewDecision.d.ts +5 -1
  49. package/dist/codex/generated/ServerNotification.d.ts +11 -0
  50. package/dist/codex/generated/ServerNotificationEnvelope.d.ts +302 -0
  51. package/dist/codex/generated/ServerNotificationEnvelope.js +3 -0
  52. package/dist/codex/generated/ServerNotificationEnvelope.js.map +1 -0
  53. package/dist/codex/generated/SleepItem.d.ts +7 -0
  54. package/dist/codex/generated/SleepItem.js +3 -0
  55. package/dist/codex/generated/SleepItem.js.map +1 -0
  56. package/dist/codex/generated/WebSearchItem.d.ts +8 -0
  57. package/dist/codex/generated/index.d.ts +4 -1
  58. package/dist/codex/generated/index.js.map +1 -1
  59. package/dist/codex/generated/v2/Account.d.ts +1 -2
  60. package/dist/codex/generated/v2/AppToolSummary.d.ts +8 -0
  61. package/dist/codex/generated/v2/AppToolSummary.js +3 -0
  62. package/dist/codex/generated/v2/AppToolSummary.js.map +1 -0
  63. package/dist/codex/generated/v2/AppsInstalledParams.d.ts +14 -0
  64. package/dist/codex/generated/v2/AppsInstalledParams.js +3 -0
  65. package/dist/codex/generated/v2/AppsInstalledParams.js.map +1 -0
  66. package/dist/codex/generated/v2/AppsInstalledResponse.d.ts +7 -0
  67. package/dist/codex/generated/v2/AppsInstalledResponse.js +3 -0
  68. package/dist/codex/generated/v2/AppsInstalledResponse.js.map +1 -0
  69. package/dist/codex/generated/v2/AppsReadParams.d.ts +14 -0
  70. package/dist/codex/generated/v2/AppsReadParams.js +3 -0
  71. package/dist/codex/generated/v2/AppsReadParams.js.map +1 -0
  72. package/dist/codex/generated/v2/AppsReadResponse.d.ts +8 -0
  73. package/dist/codex/generated/v2/AppsReadResponse.js +3 -0
  74. package/dist/codex/generated/v2/AppsReadResponse.js.map +1 -0
  75. package/dist/codex/generated/v2/ConfiguredHookHandler.d.ts +7 -0
  76. package/dist/codex/generated/v2/ConnectorMetadata.d.ts +15 -0
  77. package/dist/codex/generated/v2/ConnectorMetadata.js +3 -0
  78. package/dist/codex/generated/v2/ConnectorMetadata.js.map +1 -0
  79. package/dist/codex/generated/v2/DynamicToolCallOutputContentItem.d.ts +3 -0
  80. package/dist/codex/generated/v2/EnvironmentConnectionNotification.d.ts +4 -0
  81. package/dist/codex/generated/v2/EnvironmentConnectionNotification.js +3 -0
  82. package/dist/codex/generated/v2/EnvironmentConnectionNotification.js.map +1 -0
  83. package/dist/codex/generated/v2/EnvironmentStatusKind.d.ts +7 -0
  84. package/dist/codex/generated/v2/EnvironmentStatusKind.js +3 -0
  85. package/dist/codex/generated/v2/EnvironmentStatusKind.js.map +1 -0
  86. package/dist/codex/generated/v2/EnvironmentStatusParams.d.ts +9 -0
  87. package/dist/codex/generated/v2/EnvironmentStatusParams.js +3 -0
  88. package/dist/codex/generated/v2/EnvironmentStatusParams.js.map +1 -0
  89. package/dist/codex/generated/v2/EnvironmentStatusResponse.d.ts +14 -0
  90. package/dist/codex/generated/v2/EnvironmentStatusResponse.js +3 -0
  91. package/dist/codex/generated/v2/EnvironmentStatusResponse.js.map +1 -0
  92. package/dist/codex/generated/v2/ExternalAgentConfigDetectParams.d.ts +9 -0
  93. package/dist/codex/generated/v2/ExternalAgentConfigImportHistoriesReadResponse.d.ts +2 -0
  94. package/dist/codex/generated/v2/ExternalAgentConfigImportItemTypeFailure.d.ts +1 -0
  95. package/dist/codex/generated/v2/ExternalAgentConfigImportParams.d.ts +6 -1
  96. package/dist/codex/generated/v2/ExternalAgentConfigMigrationItemType.d.ts +1 -1
  97. package/dist/codex/generated/v2/ExternalAgentImportedConnectorCandidate.d.ts +6 -0
  98. package/dist/codex/generated/v2/ExternalAgentImportedConnectorCandidate.js +3 -0
  99. package/dist/codex/generated/v2/ExternalAgentImportedConnectorCandidate.js.map +1 -0
  100. package/dist/codex/generated/v2/ExternalAgentImportedConnectorSource.d.ts +1 -0
  101. package/dist/codex/generated/v2/ExternalAgentImportedConnectorSource.js +3 -0
  102. package/dist/codex/generated/v2/ExternalAgentImportedConnectorSource.js.map +1 -0
  103. package/dist/codex/generated/v2/FileSystemSpecialPath.d.ts +3 -2
  104. package/dist/codex/generated/v2/HookEventName.d.ts +1 -1
  105. package/dist/codex/generated/v2/HookMetadata.d.ts +5 -0
  106. package/dist/codex/generated/v2/InstalledApp.d.ts +21 -0
  107. package/dist/codex/generated/v2/InstalledApp.js +3 -0
  108. package/dist/codex/generated/v2/InstalledApp.js.map +1 -0
  109. package/dist/codex/generated/v2/LoginAccountParams.d.ts +4 -0
  110. package/dist/codex/generated/v2/LoginAccountResponse.d.ts +2 -0
  111. package/dist/codex/generated/v2/ManagedHooksRequirements.d.ts +1 -0
  112. package/dist/codex/generated/v2/McpToolCallAppContext.d.ts +0 -1
  113. package/dist/codex/generated/v2/MigrationDetails.d.ts +1 -0
  114. package/dist/codex/generated/v2/PluginDetail.d.ts +2 -0
  115. package/dist/codex/generated/v2/PluginShareUpdateDiscoverability.d.ts +1 -1
  116. package/dist/codex/generated/v2/PluginSummary.d.ts +1 -0
  117. package/dist/codex/generated/v2/RateLimitSnapshot.d.ts +4 -0
  118. package/dist/codex/generated/v2/RawResponseCompletedNotification.d.ts +11 -0
  119. package/dist/codex/generated/v2/RawResponseCompletedNotification.js +3 -0
  120. package/dist/codex/generated/v2/RawResponseCompletedNotification.js.map +1 -0
  121. package/dist/codex/generated/v2/ScheduledTaskSchedule.d.ts +16 -0
  122. package/dist/codex/generated/v2/ScheduledTaskSchedule.js +3 -0
  123. package/dist/codex/generated/v2/ScheduledTaskSchedule.js.map +1 -0
  124. package/dist/codex/generated/v2/ScheduledTaskSummary.d.ts +7 -0
  125. package/dist/codex/generated/v2/ScheduledTaskSummary.js +3 -0
  126. package/dist/codex/generated/v2/ScheduledTaskSummary.js.map +1 -0
  127. package/dist/codex/generated/v2/ScheduledTaskWeekday.d.ts +1 -0
  128. package/dist/codex/generated/v2/ScheduledTaskWeekday.js +3 -0
  129. package/dist/codex/generated/v2/ScheduledTaskWeekday.js.map +1 -0
  130. package/dist/codex/generated/v2/Thread.d.ts +5 -0
  131. package/dist/codex/generated/v2/ThreadForkParams.d.ts +11 -0
  132. package/dist/codex/generated/v2/ThreadItem.d.ts +2 -3
  133. package/dist/codex/generated/v2/ThreadItemEntry.d.ts +8 -0
  134. package/dist/codex/generated/v2/ThreadItemEntry.js +3 -0
  135. package/dist/codex/generated/v2/ThreadItemEntry.js.map +1 -0
  136. package/dist/codex/generated/v2/ThreadItemsListResponse.d.ts +2 -2
  137. package/dist/codex/generated/v2/ThreadRealtimeInitialItem.d.ts +8 -0
  138. package/dist/codex/generated/v2/ThreadRealtimeInitialItem.js +3 -0
  139. package/dist/codex/generated/v2/ThreadRealtimeInitialItem.js.map +1 -0
  140. package/dist/codex/generated/v2/ThreadRealtimeStartParams.d.ts +11 -4
  141. package/dist/codex/generated/v2/ThreadResumeResponse.d.ts +14 -0
  142. package/dist/codex/generated/v2/ThreadSearchOccurrence.d.ts +17 -0
  143. package/dist/codex/generated/v2/ThreadSearchOccurrence.js +3 -0
  144. package/dist/codex/generated/v2/ThreadSearchOccurrence.js.map +1 -0
  145. package/dist/codex/generated/v2/ThreadSearchOccurrencesParams.d.ts +19 -0
  146. package/dist/codex/generated/v2/ThreadSearchOccurrencesParams.js +3 -0
  147. package/dist/codex/generated/v2/ThreadSearchOccurrencesParams.js.map +1 -0
  148. package/dist/codex/generated/v2/ThreadSearchOccurrencesResponse.d.ts +11 -0
  149. package/dist/codex/generated/v2/ThreadSearchOccurrencesResponse.js +3 -0
  150. package/dist/codex/generated/v2/ThreadSearchOccurrencesResponse.js.map +1 -0
  151. package/dist/codex/generated/v2/ThreadSearchTextRange.d.ts +13 -0
  152. package/dist/codex/generated/v2/ThreadSearchTextRange.js +3 -0
  153. package/dist/codex/generated/v2/ThreadSearchTextRange.js.map +1 -0
  154. package/dist/codex/generated/v2/TokenUsageBreakdown.d.ts +1 -0
  155. package/dist/codex/generated/v2/TurnEnvironmentParams.d.ts +4 -0
  156. package/dist/codex/generated/v2/UserInput.d.ts +6 -0
  157. package/dist/codex/generated/v2/index.d.ts +23 -0
  158. package/dist/daemon/daemon.js +5 -20
  159. package/dist/daemon/daemon.js.map +1 -1
  160. package/dist/daemon/probe.js +1 -1
  161. package/dist/daemon/settings.d.ts +6 -0
  162. package/dist/daemon/settings.js +23 -0
  163. package/dist/daemon/settings.js.map +1 -0
  164. package/dist/gateway/clientConnection.d.ts +3 -2
  165. package/dist/gateway/clientConnection.js +41 -44
  166. package/dist/gateway/clientConnection.js.map +1 -1
  167. package/dist/gateway/logicalThreadProjection.d.ts +5 -0
  168. package/dist/gateway/logicalThreadProjection.js +58 -1
  169. package/dist/gateway/logicalThreadProjection.js.map +1 -1
  170. package/dist/gateway/remoteControlController.d.ts +29 -0
  171. package/dist/gateway/remoteControlController.js +78 -0
  172. package/dist/gateway/remoteControlController.js.map +1 -0
  173. package/dist/gateway/remoteControlHub.js +2 -1
  174. package/dist/gateway/remoteControlHub.js.map +1 -1
  175. package/dist/gateway/remoteControlMode.js +2 -12
  176. package/dist/gateway/remoteControlMode.js.map +1 -1
  177. package/dist/gateway/server.js +42 -15
  178. package/dist/gateway/server.js.map +1 -1
  179. package/dist/gateway/subscriptions.d.ts +9 -2
  180. package/dist/gateway/subscriptions.js +40 -9
  181. package/dist/gateway/subscriptions.js.map +1 -1
  182. package/dist/gateway/threadList.d.ts +25 -0
  183. package/dist/gateway/threadList.js +112 -26
  184. package/dist/gateway/threadList.js.map +1 -1
  185. package/dist/handoff/lineageStore.d.ts +101 -0
  186. package/dist/handoff/lineageStore.js +502 -0
  187. package/dist/handoff/lineageStore.js.map +1 -0
  188. package/dist/handoff/providerEpochs.d.ts +19 -5
  189. package/dist/handoff/providerEpochs.js +119 -41
  190. package/dist/handoff/providerEpochs.js.map +1 -1
  191. package/dist/handoff/service.d.ts +16 -2
  192. package/dist/handoff/service.js +595 -174
  193. package/dist/handoff/service.js.map +1 -1
  194. package/dist/handoff/startupMigration.d.ts +22 -0
  195. package/dist/handoff/startupMigration.js +52 -0
  196. package/dist/handoff/startupMigration.js.map +1 -0
  197. package/dist/handoff/store.d.ts +10 -0
  198. package/dist/handoff/store.js +141 -28
  199. package/dist/handoff/store.js.map +1 -1
  200. package/dist/runtime/dependencies.js +1 -1
  201. package/dist/store/HybridStore.d.ts +3 -1
  202. package/dist/store/HybridStore.js.map +1 -1
  203. package/dist/store/memoryStore.d.ts +3 -2
  204. package/dist/store/memoryStore.js +18 -4
  205. package/dist/store/memoryStore.js.map +1 -1
  206. package/dist/store/sqliteStore.d.ts +1 -2
  207. package/dist/store/sqliteStore.js +57 -24
  208. package/dist/store/sqliteStore.js.map +1 -1
  209. package/legal/LICENSES.md +1 -1
  210. package/legal/THIRD_PARTY_NOTICES.md +136 -124
  211. package/package.json +5 -5
  212. package/dist/codex/generated/AmazonBedrockCredentialSource.d.ts +0 -1
  213. package/dist/codex/generated/AmazonBedrockCredentialSource.js +0 -3
  214. package/dist/codex/generated/AmazonBedrockCredentialSource.js.map +0 -1
@@ -1,11 +1,33 @@
1
1
  import { DEFAULT_RENAME_PROMPT } from "../config/config.js";
2
- import { projectRpcToBackendThread, projectRpcToPublicThread } from "../gateway/logicalThreadProjection.js";
2
+ import { projectItemIds, projectRpcToBackendThread, projectRpcToPublicThread, } from "../gateway/logicalThreadProjection.js";
3
3
  import { invalidParams } from "../protocol/errors.js";
4
4
  import { filterSortThreads } from "../store/threadFilter.js";
5
5
  import { v7 as uuidv7 } from "uuid";
6
- import { ProviderEpochs } from "./providerEpochs.js";
6
+ import { stockSwitchThreadSource, ProviderEpochs, } from "./providerEpochs.js";
7
+ import { LineageStore } from "./lineageStore.js";
7
8
  import { normalizedTitlePayload, rewrittenTitlePrompt, titlePrompt } from "./titleGeneration.js";
8
9
  export const HANDOFF_DAEMON_CONNECTION_ID = "ccodex-handoff-daemon";
10
+ function testLineage(store) {
11
+ const mappings = store.listBackendMappings();
12
+ const snapshots = {
13
+ threads: new Map(mappings.flatMap((mapping) => {
14
+ const thread = store.getLogicalThread(mapping.publicThreadId)?.thread;
15
+ return thread ? [[mapping.publicThreadId, thread]] : [];
16
+ })),
17
+ epochs: new Map(mappings.flatMap((mapping) => {
18
+ const epoch = store.getEpoch(mapping.epochId);
19
+ return epoch ? [[mapping.epochId, epoch]] : [];
20
+ })),
21
+ };
22
+ const lineage = new LineageStore(store.path);
23
+ if (lineage.needsLegacyMigration()) {
24
+ const refs = lineage.legacyBackendRefs();
25
+ // Tests which omit an explicit LineageStore use an isolated temporary DB.
26
+ // Production always supplies the provider-validated store from startupMigration.
27
+ lineage.finalizeLegacyMigration(new Set(refs.map((ref) => `${ref.provider}:${ref.backendThreadId}`)));
28
+ }
29
+ return { lineage, snapshots };
30
+ }
9
31
  const SUMMARY_INSTRUCTIONS = `Create a compact, portable handoff for another coding agent/provider.
10
32
  Return plain text only. Preserve: user goal, important decisions and constraints, repository/cwd, files and symbols touched,
11
33
  commands and tests run, current state, unresolved bugs, and exact next steps. Do not call tools. Do not continue the task.
@@ -26,12 +48,73 @@ function handoffInstructions(original, summary) {
26
48
  return [original?.trim(), `[Cross-provider compact handoff]\n${summary}\n[End cross-provider compact handoff]`]
27
49
  .filter(Boolean).join("\n\n");
28
50
  }
29
- function sandboxMode(policy) {
30
- if (policy.type === "dangerFullAccess")
31
- return "danger-full-access";
32
- if (policy.type === "readOnly")
33
- return "read-only";
34
- return "workspace-write";
51
+ function value(...candidates) {
52
+ return candidates.find((candidate) => candidate !== undefined && candidate !== null);
53
+ }
54
+ function object(value) {
55
+ return value && typeof value === "object" && !Array.isArray(value)
56
+ ? value
57
+ : undefined;
58
+ }
59
+ function sandboxPermission(value) {
60
+ if (value === "danger-full-access")
61
+ return ":danger-full-access";
62
+ if (value === "read-only")
63
+ return ":read-only";
64
+ if (value === "workspace-write")
65
+ return ":workspace";
66
+ const type = object(value)?.type;
67
+ if (type === "dangerFullAccess")
68
+ return ":danger-full-access";
69
+ if (type === "readOnly")
70
+ return ":read-only";
71
+ if (type === "workspaceWrite" || type === "externalSandbox")
72
+ return ":workspace";
73
+ return undefined;
74
+ }
75
+ function activePermission(value) {
76
+ const id = object(value)?.id;
77
+ return typeof id === "string" ? id : undefined;
78
+ }
79
+ function explicitPermissions(source, staged = {}, turn = {}) {
80
+ const permissions = value(typeof turn.permissions === "string" ? turn.permissions : undefined, sandboxPermission(turn.sandboxPolicy), typeof staged.permissions === "string" ? staged.permissions : undefined, sandboxPermission(staged.sandboxPolicy), typeof source.permissions === "string" ? source.permissions : undefined, activePermission(source.activePermissionProfile), sandboxPermission(source.sandboxPolicy), sandboxPermission(source.sandbox)) ?? ":workspace";
81
+ return {
82
+ approvalPolicy: value(turn.approvalPolicy, staged.approvalPolicy, source.approvalPolicy) ?? (permissions === ":danger-full-access" ? "never" : "on-request"),
83
+ approvalsReviewer: value(turn.approvalsReviewer, staged.approvalsReviewer, source.approvalsReviewer) ?? "user",
84
+ permissions,
85
+ };
86
+ }
87
+ function explicitPermissionParams(params, permissions) {
88
+ const { sandbox: _sandbox, sandboxPolicy: _sandboxPolicy, ...rest } = params;
89
+ return { ...rest, ...permissions };
90
+ }
91
+ function permissionSandbox(permissions, cwd, fallback) {
92
+ if (permissions === ":danger-full-access")
93
+ return { type: "dangerFullAccess" };
94
+ if (permissions === ":read-only")
95
+ return { type: "readOnly", networkAccess: false };
96
+ if (permissions === ":workspace") {
97
+ return {
98
+ type: "workspaceWrite",
99
+ writableRoots: [cwd],
100
+ networkAccess: false,
101
+ excludeTmpdirEnvVar: false,
102
+ excludeSlashTmp: false,
103
+ };
104
+ }
105
+ return fallback;
106
+ }
107
+ function canonicalSettings(source, staged, turn, cwd) {
108
+ const permissions = explicitPermissions(source, staged, turn);
109
+ const { threadId: _threadId, ...merged } = { ...source, ...staged };
110
+ const sandbox = permissionSandbox(permissions.permissions, cwd, turn.sandboxPolicy ?? staged.sandboxPolicy ?? source.sandboxPolicy ?? source.sandbox);
111
+ return {
112
+ ...merged,
113
+ ...permissions,
114
+ sandbox,
115
+ sandboxPolicy: sandbox,
116
+ activePermissionProfile: { id: permissions.permissions, extends: null },
117
+ };
35
118
  }
36
119
  function agentText(turn) {
37
120
  return turn.items.flatMap((item) => item.type === "agentMessage" ? [item.text] : []).join("\n").trim();
@@ -52,12 +135,15 @@ function stagedSettings(base, pending) {
52
135
  const settings = pending.settings;
53
136
  const effort = settings.effort === undefined ? base.effort : settings.effort;
54
137
  const collaborationMode = settings.collaborationMode ?? base.collaborationMode;
138
+ const permissions = explicitPermissions(base, settings);
139
+ const sandbox = permissionSandbox(permissions.permissions, settings.cwd ?? base.cwd, settings.sandboxPolicy ?? base.sandboxPolicy);
55
140
  return {
56
141
  ...base,
57
142
  ...(settings.cwd !== undefined && settings.cwd !== null ? { cwd: settings.cwd } : {}),
58
- ...(settings.approvalPolicy !== undefined && settings.approvalPolicy !== null ? { approvalPolicy: settings.approvalPolicy } : {}),
59
- ...(settings.approvalsReviewer !== undefined && settings.approvalsReviewer !== null ? { approvalsReviewer: settings.approvalsReviewer } : {}),
60
- ...(settings.sandboxPolicy !== undefined && settings.sandboxPolicy !== null ? { sandboxPolicy: settings.sandboxPolicy } : {}),
143
+ approvalPolicy: permissions.approvalPolicy,
144
+ approvalsReviewer: permissions.approvalsReviewer,
145
+ sandboxPolicy: sandbox,
146
+ activePermissionProfile: { id: permissions.permissions, extends: null },
61
147
  model: pending.targetModel,
62
148
  modelProvider: pending.targetProvider === "claude" ? "claude" : "openai",
63
149
  serviceTier: settings.serviceTier === undefined ? base.serviceTier : settings.serviceTier,
@@ -98,7 +184,9 @@ function pageItems(turns, params) {
98
184
  const offset = params.cursor ? Number(params.cursor.slice("hyb-overlay-item:".length)) : 0;
99
185
  if (!Number.isInteger(offset) || offset < 0)
100
186
  throw invalidParams("Invalid handoff item cursor.");
101
- const items = turns.flatMap((turn) => params.turnId && turn.id !== params.turnId ? [] : turn.items);
187
+ const items = turns.flatMap((turn) => params.turnId && turn.id !== params.turnId
188
+ ? []
189
+ : turn.items.map((item) => ({ turnId: turn.id, item })));
102
190
  const ordered = params.sortDirection === "desc" ? [...items].reverse() : items;
103
191
  const limit = Math.max(1, Math.min(params.limit ?? 50, 100));
104
192
  const data = ordered.slice(offset, offset + limit);
@@ -142,20 +230,29 @@ export class CrossProviderForks {
142
230
  stockServerRequests = new Map();
143
231
  stockServerRequestAliases = new Map();
144
232
  recentDurableProviders = new Map();
233
+ adminTails = new Map();
234
+ lineage;
235
+ legacyMirror;
145
236
  daemonStock;
146
237
  subscriptions;
147
238
  jobTail = Promise.resolve();
148
- constructor(store, claude, renamePrompt = DEFAULT_RENAME_PROMPT) {
239
+ constructor(store, claude, renamePrompt = DEFAULT_RENAME_PROMPT, lineage) {
149
240
  this.store = store;
150
241
  this.claude = claude;
151
242
  this.renamePrompt = renamePrompt;
152
- this.epochs = new ProviderEpochs(store);
243
+ const fallback = lineage ? undefined : testLineage(store);
244
+ const providerLineage = lineage ?? fallback.lineage;
245
+ this.lineage = providerLineage;
246
+ this.legacyMirror = lineage === undefined;
247
+ this.epochs = new ProviderEpochs(providerLineage, this.legacyMirror ? store : undefined, fallback?.snapshots);
153
248
  }
154
249
  pending(threadId) { return this.store.getPending(threadId); }
155
250
  logical(threadId) { return this.epochs.resolve(threadId); }
156
251
  hiddenBackendIds(provider) { return this.epochs.hiddenBackendIds(provider); }
157
- sideSnapshot(params, targetThreadId) {
158
- const source = this.epochs.resolve(params.threadId);
252
+ catalogTombstones() { return this.store.remoteCatalogTombstones(); }
253
+ async sideSnapshot(params, targetThreadId) {
254
+ const unresolved = this.epochs.resolve(params.threadId);
255
+ const source = unresolved && await this.hydrate(unresolved, this.daemonStock);
159
256
  if (!source)
160
257
  return undefined;
161
258
  const settings = source.epoch.settings;
@@ -191,7 +288,7 @@ export class CrossProviderForks {
191
288
  }
192
289
  projectThreadCatalog(stockThreads, claudeThreads, params = {}) {
193
290
  const physical = new Map([...stockThreads, ...claudeThreads].map((thread) => [thread.id, thread]));
194
- const mappings = this.store.listBackendMappings();
291
+ const mappings = this.epochs.mappings();
195
292
  const hidden = new Set([
196
293
  ...mappings.map((mapping) => mapping.backendThreadId),
197
294
  ...this.store.hiddenProviderSwitchTargetIds(),
@@ -215,7 +312,7 @@ export class CrossProviderForks {
215
312
  }
216
313
  projectLoadedThreadIds(stockIds, claudeIds) {
217
314
  const loaded = new Set([...stockIds, ...claudeIds]);
218
- const mappings = this.store.listBackendMappings();
315
+ const mappings = this.epochs.mappings();
219
316
  const hidden = new Set([
220
317
  ...mappings.map((mapping) => mapping.backendThreadId),
221
318
  ...this.store.hiddenProviderSwitchTargetIds(),
@@ -231,7 +328,7 @@ export class CrossProviderForks {
231
328
  const backendThreadId = messageThreadId({ params: message.params });
232
329
  if (!backendThreadId)
233
330
  return false;
234
- const epoch = this.store.findEpochByBackend("stock", backendThreadId);
331
+ const epoch = this.lineage.findEpoch("stock", backendThreadId);
235
332
  if (!epoch || epoch.state !== "current" || !this.subscriptions)
236
333
  return false;
237
334
  if (message.method === "serverRequest/resolved" && message.params && typeof message.params === "object") {
@@ -264,7 +361,7 @@ export class CrossProviderForks {
264
361
  }
265
362
  ownsStockBackendMessage(message) {
266
363
  const backendThreadId = messageThreadId(message);
267
- return backendThreadId !== undefined && this.store.findEpochByBackend("stock", backendThreadId) !== undefined;
364
+ return backendThreadId !== undefined && this.lineage.findEpoch("stock", backendThreadId) !== undefined;
268
365
  }
269
366
  async resolveStockServerRequest(requestId, result) {
270
367
  const stockRequestId = this.stockServerRequests.get(requestId);
@@ -278,11 +375,24 @@ export class CrossProviderForks {
278
375
  const publicThreadId = publicParams.threadId;
279
376
  if (typeof publicThreadId !== "string")
280
377
  throw invalidParams("Logical thread request has no threadId.");
281
- const resolved = this.epochs.resolve(publicThreadId);
282
- if (!resolved)
378
+ const unresolved = this.epochs.resolve(publicThreadId);
379
+ if (!unresolved)
283
380
  throw invalidParams(`Unknown logical thread '${publicThreadId}'.`);
284
- const owner = { publicThreadId, backendThreadId: resolved.epoch.backendThreadId };
285
- const params = projectRpcToBackendThread({ params: publicParams }, owner).params;
381
+ // An archived stock backend rejects read/resume, so unarchive must reach the
382
+ // provider before metadata hydration. The lineage mapping is sufficient to
383
+ // route this lifecycle operation.
384
+ const resolved = method === "thread/unarchive"
385
+ ? unresolved
386
+ : await this.hydrate(unresolved, this.daemonStock ?? clientStock);
387
+ const owner = {
388
+ publicThreadId,
389
+ backendThreadId: resolved.epoch.backendThreadId,
390
+ itemNamespace: resolved.epoch.id,
391
+ };
392
+ let params = projectRpcToBackendThread({ params: publicParams }, owner).params;
393
+ if (method === "thread/settings/update" || method === "turn/start") {
394
+ params = explicitPermissionParams(params, explicitPermissions(resolved.epoch.settings, publicParams, method === "turn/start" ? publicParams : {}));
395
+ }
286
396
  if (method === "thread/delete") {
287
397
  await this.deleteLogicalThread(publicThreadId, clientStock);
288
398
  return { provider: resolved.epoch.provider, result: {} };
@@ -299,6 +409,10 @@ export class CrossProviderForks {
299
409
  }
300
410
  if (resolved.epoch.provider === "stock") {
301
411
  const stock = this.daemonStock ?? clientStock;
412
+ if (method === "thread/name/set") {
413
+ const result = await this.withAdminLock(publicThreadId, () => stock.request(method, params));
414
+ return { provider: "stock", result: projectRpcToPublicThread({ result }, owner).result };
415
+ }
302
416
  if (method === "thread/read" || method === "thread/resume") {
303
417
  const result = await stock.request(method, params);
304
418
  const resume = method === "thread/resume" ? params : undefined;
@@ -312,7 +426,7 @@ export class CrossProviderForks {
312
426
  })).thread.turns
313
427
  : result.thread.turns;
314
428
  const initialTurnsPage = resume?.initialTurnsPage
315
- ? pageTurns(this.epochs.visibleTurns(publicThreadId, backendTurns), {
429
+ ? pageTurns(await this.visibleTurns(publicThreadId, backendTurns, stock), {
316
430
  threadId: publicThreadId,
317
431
  ...resume.initialTurnsPage,
318
432
  })
@@ -321,7 +435,7 @@ export class CrossProviderForks {
321
435
  provider: "stock",
322
436
  result: {
323
437
  ...result,
324
- thread: this.epochs.projectThread(publicThreadId, result.thread, includeTurns),
438
+ thread: this.epochs.projectThread(publicThreadId, projectItemIds(result.thread, resolved.epoch.id), includeTurns, includeTurns ? (await this.historicalTurns(publicThreadId, stock)).map((entry) => entry.turn) : []),
325
439
  ...(resume ? { initialTurnsPage } : {}),
326
440
  },
327
441
  };
@@ -331,7 +445,7 @@ export class CrossProviderForks {
331
445
  threadId: resolved.epoch.backendThreadId,
332
446
  includeTurns: true,
333
447
  });
334
- const turns = this.epochs.visibleTurns(publicThreadId, read.thread.turns);
448
+ const turns = await this.visibleTurns(publicThreadId, read.thread.turns, stock);
335
449
  return {
336
450
  provider: "stock",
337
451
  result: method === "thread/turns/list"
@@ -343,9 +457,6 @@ export class CrossProviderForks {
343
457
  if (method === "thread/settings/update" || method === "turn/start") {
344
458
  this.updateLogicalEpochFromParams(resolved, publicParams);
345
459
  }
346
- if (method === "thread/name/set" && typeof publicParams.name === "string") {
347
- this.patchLogicalThread(publicThreadId, { name: publicParams.name });
348
- }
349
460
  return {
350
461
  provider: "stock",
351
462
  result: projectRpcToPublicThread({ result }, owner).result,
@@ -354,14 +465,18 @@ export class CrossProviderForks {
354
465
  const threadId = resolved.epoch.backendThreadId;
355
466
  if (method === "thread/read") {
356
467
  const includeTurns = params.includeTurns ?? false;
357
- const thread = this.epochs.projectThread(publicThreadId, this.claude.readThread(threadId, includeTurns).thread, includeTurns);
468
+ const thread = this.epochs.projectThread(publicThreadId, projectItemIds(this.claude.readThread(threadId, includeTurns).thread, resolved.epoch.id), includeTurns, includeTurns
469
+ ? (await this.historicalTurns(publicThreadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)
470
+ : []);
358
471
  return { provider: "claude", result: { thread } };
359
472
  }
360
473
  if (method === "thread/resume") {
361
474
  const resume = params;
362
475
  const prepared = await this.claude.prepareResume(resume);
363
476
  const response = prepared.response;
364
- const thread = this.epochs.projectThread(publicThreadId, response.thread, !resume.excludeTurns);
477
+ const thread = this.epochs.projectThread(publicThreadId, projectItemIds(response.thread, resolved.epoch.id), !resume.excludeTurns, !resume.excludeTurns
478
+ ? (await this.historicalTurns(publicThreadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)
479
+ : []);
365
480
  const backendTurns = resume.initialTurnsPage && resume.excludeTurns
366
481
  ? this.claude.readThread(threadId, true).thread.turns
367
482
  : response.thread.turns;
@@ -371,7 +486,7 @@ export class CrossProviderForks {
371
486
  ...response,
372
487
  thread,
373
488
  ...(resume.initialTurnsPage ? {
374
- initialTurnsPage: pageTurns(this.epochs.visibleTurns(publicThreadId, backendTurns), {
489
+ initialTurnsPage: pageTurns(await this.visibleTurns(publicThreadId, backendTurns, this.daemonStock ?? clientStock), {
375
490
  threadId: publicThreadId,
376
491
  ...resume.initialTurnsPage,
377
492
  }),
@@ -386,14 +501,14 @@ export class CrossProviderForks {
386
501
  const read = this.claude.readThread(threadId, true).thread;
387
502
  return {
388
503
  provider: "claude",
389
- result: pageTurns(this.epochs.visibleTurns(publicThreadId, read.turns), publicParams),
504
+ result: pageTurns(await this.visibleTurns(publicThreadId, read.turns, this.daemonStock ?? clientStock), publicParams),
390
505
  };
391
506
  }
392
507
  if (method === "thread/items/list") {
393
508
  const read = this.claude.readThread(threadId, true).thread;
394
509
  return {
395
510
  provider: "claude",
396
- result: pageItems(this.epochs.visibleTurns(publicThreadId, read.turns), publicParams),
511
+ result: pageItems(await this.visibleTurns(publicThreadId, read.turns, this.daemonStock ?? clientStock), publicParams),
397
512
  };
398
513
  }
399
514
  if (method === "thread/settings/update") {
@@ -405,10 +520,15 @@ export class CrossProviderForks {
405
520
  };
406
521
  }
407
522
  if (method === "thread/name/set") {
408
- const result = await this.claude.setThreadName(params);
409
- if (typeof publicParams.name === "string")
410
- this.patchLogicalThread(publicThreadId, { name: publicParams.name });
411
- return { provider: "claude", result };
523
+ const buffer = this.subscriptions?.bufferLifecycle(threadId, ["thread/name/updated"]);
524
+ try {
525
+ const result = await this.withAdminLock(publicThreadId, () => this.claude.setThreadName(params));
526
+ return { provider: "claude", result, after: () => buffer?.flush() };
527
+ }
528
+ catch (error) {
529
+ buffer?.discard();
530
+ throw error;
531
+ }
412
532
  }
413
533
  if (method === "thread/metadata/update") {
414
534
  return {
@@ -417,10 +537,26 @@ export class CrossProviderForks {
417
537
  };
418
538
  }
419
539
  if (method === "thread/archive") {
420
- return { provider: "claude", result: await this.claude.archiveThread(threadId) };
540
+ const buffer = this.subscriptions?.bufferLifecycle(threadId, ["thread/archived"]);
541
+ try {
542
+ const result = await this.claude.archiveThread(threadId);
543
+ return { provider: "claude", result, after: () => buffer?.flush() };
544
+ }
545
+ catch (error) {
546
+ buffer?.discard();
547
+ throw error;
548
+ }
421
549
  }
422
550
  if (method === "thread/unarchive") {
423
- return { provider: "claude", result: await this.claude.unarchiveThread(threadId) };
551
+ const buffer = this.subscriptions?.bufferLifecycle(threadId, ["thread/unarchived"]);
552
+ try {
553
+ const result = await this.claude.unarchiveThread(threadId);
554
+ return { provider: "claude", result, after: () => buffer?.flush() };
555
+ }
556
+ catch (error) {
557
+ buffer?.discard();
558
+ throw error;
559
+ }
424
560
  }
425
561
  if (method === "thread/compact/start") {
426
562
  return { provider: "claude", result: await this.claude.compactThread(threadId) };
@@ -536,23 +672,25 @@ export class CrossProviderForks {
536
672
  throw invalidParams("This fork is waiting for Codex App to select a completed history boundary.");
537
673
  }
538
674
  async forkLogical(params, clientStock, connectionId) {
539
- const source = this.epochs.resolve(params.threadId);
540
- if (!source)
675
+ const unresolvedSource = this.epochs.resolve(params.threadId);
676
+ if (!unresolvedSource)
541
677
  throw invalidParams(`Unknown logical thread '${params.threadId}'.`);
678
+ const source = await this.hydrate(unresolvedSource, this.daemonStock ?? clientStock);
542
679
  if (params.model && providerForModel(this.claude, params.model) !== source.epoch.provider) {
543
680
  throw invalidParams("Fork is same-provider only. Change the model and send a message to switch provider.");
544
681
  }
545
682
  const currentTurns = await this.currentBackendTurns(source, this.daemonStock ?? clientStock);
546
- const visible = this.epochs.snapshotTurns(params.threadId, currentTurns);
683
+ const visible = await this.snapshotTurns(params.threadId, currentTurns, this.daemonStock ?? clientStock);
547
684
  const boundaryIndex = visible.findLastIndex((turn) => turn.turn.status === "completed" && turn.epochId && turn.providerTurnId);
548
685
  const boundary = visible[boundaryIndex];
549
686
  if (!boundary?.epochId || !boundary.providerTurnId) {
550
687
  throw invalidParams("Cannot fork before the task has a completed provider turn.");
551
688
  }
552
- const selectedEpoch = this.store.getEpoch(boundary.epochId);
553
- if (!selectedEpoch || !this.store.epochBelongsToLineage(params.threadId, selectedEpoch.id)) {
689
+ const selectedBoundary = this.lineage.getEpoch(boundary.epochId);
690
+ if (!selectedBoundary || !this.lineage.epochBelongsToLineage(params.threadId, selectedBoundary.epochId)) {
554
691
  throw invalidParams("Fork boundary is outside the task's provider lineage.");
555
692
  }
693
+ const selectedEpoch = await this.hydrateEpoch(selectedBoundary.epochId, this.daemonStock ?? clientStock);
556
694
  const targetId = uuidv7();
557
695
  let forked;
558
696
  let stockBuildOwner;
@@ -604,21 +742,13 @@ export class CrossProviderForks {
604
742
  turns: [],
605
743
  };
606
744
  try {
607
- const target = this.store.createLogicalThread({
608
- thread,
609
- epoch: {
610
- id: uuidv7(),
611
- provider: selectedEpoch.provider,
612
- backendThreadId: forked.thread.id,
613
- model: selectedEpoch.model,
614
- settings: selectedEpoch.settings,
615
- },
616
- });
617
- const inherited = visible.slice(0, boundaryIndex + 1).filter((turn) => turn.epochId !== selectedEpoch.id);
618
- if (inherited.length > 0 && !this.store.replaceLogicalTurns(targetId, target.revision, inherited)) {
619
- throw new Error("Failed to persist logical fork history.");
620
- }
621
- this.subscriptions?.aliasThread(forked.thread.id, targetId);
745
+ const sourceSegments = this.lineage.listSegments(params.threadId);
746
+ const selectedSegment = sourceSegments.findIndex((segment) => segment.kind === "provider" && segment.epochId === selectedEpoch.id);
747
+ const inheritedSegments = (selectedSegment < 0 ? sourceSegments : sourceSegments.slice(0, selectedSegment))
748
+ .map(({ position: _position, ...segment }) => segment);
749
+ this.epochs.createFork(thread, forked.thread.id, selectedEpoch.provider, selectedEpoch.model, selectedEpoch.settings, inheritedSegments);
750
+ const targetEpochId = this.epochs.resolve(targetId).epoch.id;
751
+ this.subscriptions?.aliasThread(forked.thread.id, targetId, targetEpochId);
622
752
  if (stockBuildOwner)
623
753
  this.stockTargetBuilds.delete(stockBuildOwner);
624
754
  if (selectedEpoch.provider === "claude") {
@@ -626,13 +756,15 @@ export class CrossProviderForks {
626
756
  }
627
757
  else {
628
758
  this.subscriptions?.emitPublic(targetId, "thread/started", {
629
- thread: this.epochs.projectThread(targetId, forked.thread, false),
759
+ thread: this.epochs.projectThread(targetId, projectItemIds(forked.thread, targetEpochId), false),
630
760
  });
631
761
  }
632
762
  const settings = selectedEpoch.settings;
633
763
  return {
634
764
  ...forked,
635
- thread: this.epochs.projectThread(targetId, forked.thread, !params.excludeTurns),
765
+ thread: this.epochs.projectThread(targetId, projectItemIds(forked.thread, targetEpochId), !params.excludeTurns, !params.excludeTurns
766
+ ? (await this.historicalTurns(targetId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)
767
+ : []),
636
768
  model: selectedEpoch.model,
637
769
  modelProvider: selectedEpoch.provider === "claude" ? "claude" : "openai",
638
770
  serviceTier: typeof settings.serviceTier === "string" ? settings.serviceTier : null,
@@ -641,7 +773,9 @@ export class CrossProviderForks {
641
773
  catch (error) {
642
774
  if (stockBuildOwner)
643
775
  this.stockTargetBuilds.delete(stockBuildOwner);
644
- this.store.deleteLogicalThread(targetId);
776
+ this.lineage.deleteTask(targetId);
777
+ if (this.legacyMirror)
778
+ this.store.deleteLogicalThread(targetId);
645
779
  if (!forked?.thread.id)
646
780
  throw error;
647
781
  if (selectedEpoch.provider === "claude") {
@@ -655,15 +789,16 @@ export class CrossProviderForks {
655
789
  }
656
790
  async rollbackLogicalThread(params, clientStock, connectionId) {
657
791
  const selection = this.store.getForkSelection(params.threadId);
658
- const target = this.epochs.resolve(params.threadId);
659
- if (!target)
792
+ const unresolvedTarget = this.epochs.resolve(params.threadId);
793
+ if (!unresolvedTarget)
660
794
  throw invalidParams(`Unknown logical thread '${params.threadId}'.`);
795
+ const target = await this.hydrate(unresolvedTarget, this.daemonStock ?? clientStock);
661
796
  if (!Number.isInteger(params.numTurns) || params.numTurns < 0) {
662
797
  throw invalidParams("numTurns must be a non-negative integer.");
663
798
  }
664
799
  const pendingFork = selection?.status === "pending" ? selection : undefined;
665
800
  const currentTurns = await this.currentBackendTurns(target, this.daemonStock ?? clientStock);
666
- const turns = this.epochs.snapshotTurns(params.threadId, currentTurns);
801
+ const turns = await this.snapshotTurns(params.threadId, currentTurns, this.daemonStock ?? clientStock);
667
802
  const keep = turns.length - params.numTurns;
668
803
  if (keep <= 0)
669
804
  throw invalidParams("Rollback removed every provider turn.");
@@ -672,11 +807,12 @@ export class CrossProviderForks {
672
807
  if (!boundary?.epochId || !boundary.providerTurnId) {
673
808
  throw invalidParams("Rollback has no provider-backed turn boundary.");
674
809
  }
675
- const selectedEpoch = this.store.getEpoch(boundary.epochId);
810
+ const selectedBoundary = this.lineage.getEpoch(boundary.epochId);
676
811
  const selectedLineage = pendingFork?.sourcePublicThreadId ?? params.threadId;
677
- if (!selectedEpoch || !this.store.epochBelongsToLineage(selectedLineage, selectedEpoch.id)) {
812
+ if (!selectedBoundary || !this.lineage.epochBelongsToLineage(selectedLineage, selectedBoundary.epochId)) {
678
813
  throw invalidParams("Rollback points outside the thread's provider lineage.");
679
814
  }
815
+ const selectedEpoch = await this.hydrateEpoch(selectedBoundary.epochId, this.daemonStock ?? clientStock);
680
816
  if (!pendingFork && selectedEpoch.id === target.epoch.id) {
681
817
  if (params.numTurns === 0) {
682
818
  const backend = target.epoch.provider === "claude"
@@ -685,7 +821,7 @@ export class CrossProviderForks {
685
821
  threadId: target.epoch.backendThreadId,
686
822
  includeTurns: true,
687
823
  })).thread;
688
- return { thread: this.epochs.projectThread(params.threadId, backend, true) };
824
+ return { thread: this.epochs.projectThread(params.threadId, projectItemIds(backend, target.epoch.id), true, (await this.historicalTurns(params.threadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)) };
689
825
  }
690
826
  const rolled = target.epoch.provider === "claude"
691
827
  ? await this.claude.rollbackThread({
@@ -696,7 +832,7 @@ export class CrossProviderForks {
696
832
  threadId: target.epoch.backendThreadId,
697
833
  numTurns: params.numTurns,
698
834
  });
699
- return { thread: this.epochs.projectThread(params.threadId, rolled.thread, true) };
835
+ return { thread: this.epochs.projectThread(params.threadId, projectItemIds(rolled.thread, target.epoch.id), true, (await this.historicalTurns(params.threadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)) };
700
836
  }
701
837
  let forked;
702
838
  let stockBuildOwner;
@@ -743,13 +879,25 @@ export class CrossProviderForks {
743
879
  name: target.logical.thread.name,
744
880
  turns: [],
745
881
  };
746
- const committed = this.store.commitLogicalRollback({
882
+ const targetEpochId = uuidv7();
883
+ const sourceSegments = this.lineage.listSegments(selectedLineage);
884
+ const selectedSegment = sourceSegments.findIndex((segment) => segment.kind === "provider" && segment.epochId === selectedEpoch.id);
885
+ const retainedSegments = (selectedSegment < 0 ? sourceSegments : sourceSegments.slice(0, selectedSegment))
886
+ .map(({ position: _position, ...segment }) => segment);
887
+ const lineageCommitted = this.lineage.commitRollback(params.threadId, target.epoch.id, target.logical.revision, {
888
+ epochId: targetEpochId,
889
+ provider: selectedEpoch.provider,
890
+ backendThreadId: forked.thread.id,
891
+ }, retainedSegments);
892
+ if (!lineageCommitted)
893
+ throw new Error("Logical rollback lost its lineage CAS boundary.");
894
+ const committed = !this.legacyMirror || this.store.commitLogicalRollback({
747
895
  targetPublicThreadId: params.threadId,
748
896
  expectedCurrentEpochId: target.epoch.id,
749
897
  expectedThreadRevision: target.logical.revision,
750
898
  selectedEpochId: selectedEpoch.id,
751
899
  targetEpoch: {
752
- id: uuidv7(),
900
+ id: targetEpochId,
753
901
  provider: selectedEpoch.provider,
754
902
  backendThreadId: forked.thread.id,
755
903
  model: selectedEpoch.model,
@@ -770,7 +918,7 @@ export class CrossProviderForks {
770
918
  throw new Error("Logical rollback lost its atomic commit boundary.");
771
919
  }
772
920
  this.subscriptions?.suppress(target.epoch.backendThreadId);
773
- this.subscriptions?.aliasThread(forked.thread.id, params.threadId);
921
+ this.subscriptions?.aliasThread(forked.thread.id, params.threadId, targetEpochId);
774
922
  if (stockBuildOwner)
775
923
  this.stockTargetBuilds.delete(stockBuildOwner);
776
924
  if (pendingFork) {
@@ -778,7 +926,7 @@ export class CrossProviderForks {
778
926
  await this.claude.announceThread(forked.thread);
779
927
  else
780
928
  this.subscriptions?.emitPublic(params.threadId, "thread/started", {
781
- thread: this.epochs.projectThread(params.threadId, forked.thread, true),
929
+ thread: this.epochs.projectThread(params.threadId, projectItemIds(forked.thread, targetEpochId), true, (await this.historicalTurns(params.threadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)),
782
930
  });
783
931
  }
784
932
  this.subscriptions?.emitPublic(params.threadId, "thread/settings/updated", {
@@ -789,10 +937,11 @@ export class CrossProviderForks {
789
937
  modelProvider: selectedEpoch.provider === "claude" ? "claude" : "openai",
790
938
  },
791
939
  });
792
- return { thread: this.epochs.projectThread(params.threadId, forked.thread, true) };
940
+ return { thread: this.epochs.projectThread(params.threadId, projectItemIds(forked.thread, targetEpochId), true, (await this.historicalTurns(params.threadId, this.daemonStock ?? clientStock)).map((entry) => entry.turn)) };
793
941
  }
794
942
  configureDaemonStock(stock) {
795
943
  this.daemonStock = stock;
944
+ void this.settlePendingStockArchives(stock);
796
945
  for (const job of this.store.recoverableJobs()) {
797
946
  const targetId = job.target?.thread && typeof job.target.thread === "object"
798
947
  ? job.target.thread.id
@@ -808,9 +957,10 @@ export class CrossProviderForks {
808
957
  }
809
958
  configureSubscriptions(subscriptions) {
810
959
  this.subscriptions = subscriptions;
811
- for (const mapping of this.store.listBackendMappings()) {
812
- if (mapping.state === "current")
813
- subscriptions.aliasThread(mapping.backendThreadId, mapping.publicThreadId);
960
+ for (const mapping of this.lineage.listMappings()) {
961
+ if (mapping.state === "current") {
962
+ subscriptions.aliasThread(mapping.backendThreadId, mapping.publicThreadId, mapping.epochId);
963
+ }
814
964
  else
815
965
  subscriptions.suppress(mapping.backendThreadId);
816
966
  }
@@ -833,6 +983,7 @@ export class CrossProviderForks {
833
983
  publicThreadId: params.threadId,
834
984
  expectedEpochId: seeded.epoch.id,
835
985
  pendingRevision: pending.revision,
986
+ expectedThreadRevision: seeded.logical.revision,
836
987
  targetProvider: pending.targetProvider,
837
988
  targetModel: pending.targetModel,
838
989
  settings: pending.settings,
@@ -852,21 +1003,24 @@ export class CrossProviderForks {
852
1003
  const workerConnectionId = this.daemonStock
853
1004
  ? `${HANDOFF_DAEMON_CONNECTION_ID}:switch:${job.id}`
854
1005
  : connectionId;
855
- const { source, summary, sourceTurns, developerInstructions, targetSettings } = await this.providerSwitchMaterial(job, stock, workerConnectionId);
1006
+ const { source, summary, sourceTurns, developerInstructions, targetSettings, permissionSettings } = await this.providerSwitchMaterial(job, stock, workerConnectionId);
856
1007
  if (job.targetProvider === "claude") {
857
- const started = await this.claude.startHiddenThread(this.targetThreadStart(job, source.thread, developerInstructions));
1008
+ const started = await this.claude.startHiddenThread(this.targetThreadStart(job, source.thread, developerInstructions, permissionSettings));
858
1009
  target = { provider: "claude", threadId: started.thread.id };
859
1010
  if (!this.store.checkpointProviderSwitchTarget(job.id, {
860
1011
  backendThreadId: started.thread.id,
861
1012
  summary,
862
1013
  }))
863
1014
  throw new Error("Provider switch target lost its durable checkpoint.");
864
- await this.claude.updateThreadSettings({
1015
+ await this.claude.updateThreadSettings(explicitPermissionParams({
865
1016
  ...job.settings,
866
1017
  threadId: started.thread.id,
867
1018
  model: job.targetModel,
868
- });
869
- const prepared = await this.claude.prepareTurn({ ...params, threadId: started.thread.id });
1019
+ }, permissionSettings));
1020
+ const prepared = await this.claude.prepareTurn(explicitPermissionParams({
1021
+ ...params,
1022
+ threadId: started.thread.id,
1023
+ }, permissionSettings));
870
1024
  await prepared.startAndWait();
871
1025
  if (!this.store.checkpointProviderSwitchTarget(job.id, {
872
1026
  backendThreadId: started.thread.id,
@@ -874,8 +1028,9 @@ export class CrossProviderForks {
874
1028
  }))
875
1029
  throw new Error("Provider switch target delivery lost its durable checkpoint.");
876
1030
  this.subscriptions?.hideUserMessages(started.thread.id, prepared.response.turn.id);
877
- this.commitProviderSwitch(job, started.thread, targetSettings, sourceTurns);
1031
+ const sealed = await this.finalizeProviderSwitch(job, started.thread, targetSettings, sourceTurns, stock);
878
1032
  committed = true;
1033
+ await this.archiveSealedStockEpoch(sealed, stock).catch(() => undefined);
879
1034
  compacted();
880
1035
  await prepared.announce();
881
1036
  return;
@@ -884,40 +1039,50 @@ export class CrossProviderForks {
884
1039
  awaitingStart: true,
885
1040
  threadIds: new Set(),
886
1041
  messages: [],
1042
+ expectedThreadSource: stockSwitchThreadSource(job.id, source.thread.threadSource),
887
1043
  };
888
1044
  this.stockTargetBuilds.set(workerConnectionId, build);
889
- const started = await stock.request("thread/start", this.targetThreadStart(job, source.thread, developerInstructions));
1045
+ const started = await stock.request("thread/start", {
1046
+ ...this.targetThreadStart(job, source.thread, developerInstructions, permissionSettings),
1047
+ threadSource: build.expectedThreadSource,
1048
+ });
890
1049
  target = { provider: "stock", threadId: started.thread.id };
891
1050
  if (build.awaitingStart)
892
1051
  build.awaitingStart = false;
893
1052
  build.threadIds.add(started.thread.id);
894
- await stock.request("thread/settings/update", {
1053
+ await stock.request("thread/settings/update", explicitPermissionParams({
895
1054
  ...job.settings,
896
1055
  threadId: started.thread.id,
897
1056
  model: job.targetModel,
898
- });
1057
+ }, permissionSettings));
899
1058
  if (!this.store.checkpointProviderSwitchTarget(job.id, {
900
1059
  backendThreadId: started.thread.id,
901
1060
  summary,
902
1061
  }))
903
1062
  throw new Error("Provider switch target lost its durable checkpoint.");
904
- const delivered = await stock.request("turn/start", {
1063
+ const delivered = await stock.request("turn/start", explicitPermissionParams({
905
1064
  ...params,
906
1065
  threadId: started.thread.id,
907
- });
1066
+ }, permissionSettings));
908
1067
  if (!this.store.checkpointProviderSwitchTarget(job.id, {
909
1068
  backendThreadId: started.thread.id,
910
1069
  providerTurnId: delivered.turn.id,
911
1070
  }))
912
1071
  throw new Error("Provider switch target delivery lost its durable checkpoint.");
913
1072
  this.subscriptions?.hideUserMessages(started.thread.id, delivered.turn.id);
914
- this.commitProviderSwitch(job, started.thread, targetSettings, sourceTurns);
1073
+ const sealed = await this.finalizeProviderSwitch(job, started.thread, targetSettings, sourceTurns, stock);
915
1074
  committed = true;
1075
+ await this.archiveSealedStockEpoch(sealed, stock).catch(() => undefined);
916
1076
  compacted();
917
1077
  this.flushStockTargetBuild(workerConnectionId);
918
1078
  }
919
1079
  catch (error) {
920
1080
  const failure = error instanceof Error ? error : new Error(String(error));
1081
+ const current = this.lineage.getTask(job.publicThreadId);
1082
+ const currentEpoch = current && this.lineage.getEpoch(current.currentEpochId);
1083
+ if (target && currentEpoch?.backendThreadId === target.threadId
1084
+ && currentEpoch.provider === target.provider)
1085
+ committed = true;
921
1086
  if (committed)
922
1087
  return;
923
1088
  this.store.failProviderSwitch(job.id, failure.message);
@@ -939,6 +1104,21 @@ export class CrossProviderForks {
939
1104
  }
940
1105
  }
941
1106
  async recoverProviderSwitch(job, stock) {
1107
+ const currentTask = this.lineage.getTask(job.publicThreadId);
1108
+ const currentEpoch = currentTask && this.lineage.getEpoch(currentTask.currentEpochId);
1109
+ if (job.targetBackendThreadId && currentEpoch?.backendThreadId === job.targetBackendThreadId
1110
+ && currentEpoch.provider === job.targetProvider) {
1111
+ this.store.completeProviderSwitch(job.id);
1112
+ const source = this.lineage.getEpoch(job.expectedEpochId);
1113
+ if (source?.provider === "stock" && source.archivePending) {
1114
+ await stock.request("thread/archive", { threadId: source.backendThreadId }).then(() => {
1115
+ this.lineage.markStockArchived(source.epochId);
1116
+ if (this.legacyMirror)
1117
+ this.store.markStockArchived(source.epochId);
1118
+ }, () => undefined);
1119
+ }
1120
+ return;
1121
+ }
942
1122
  if (job.targetProvider === "claude" && job.targetBackendThreadId) {
943
1123
  this.subscriptions?.suppress(job.targetBackendThreadId);
944
1124
  }
@@ -951,7 +1131,8 @@ export class CrossProviderForks {
951
1131
  })).thread;
952
1132
  if (target.turns.some((turn) => turn.id === job.targetProviderTurnId)) {
953
1133
  const material = await this.providerSwitchMaterial(job, stock, `${HANDOFF_DAEMON_CONNECTION_ID}:recovery:${job.id}`);
954
- this.commitProviderSwitch(job, target, material.targetSettings, material.sourceTurns);
1134
+ const sealed = await this.finalizeProviderSwitch(job, target, material.targetSettings, material.sourceTurns, stock);
1135
+ await this.archiveSealedStockEpoch(sealed, stock).catch(() => undefined);
955
1136
  return;
956
1137
  }
957
1138
  }
@@ -1241,14 +1422,22 @@ export class CrossProviderForks {
1241
1422
  return false;
1242
1423
  if (message.method === "thread/started" && this.systemEphemeralThreads.get(connectionId)?.has(threadId))
1243
1424
  return true;
1244
- if (message.method === "thread/started" && this.store.findEpochByBackend("stock", threadId))
1425
+ if (message.method === "thread/started" && this.lineage.findEpoch("stock", threadId))
1245
1426
  return true;
1246
1427
  const direct = this.stockTargetBuilds.get(connectionId);
1247
1428
  if (direct && message.method === "thread/started" && direct.awaitingStart
1248
- && (direct.expectedForkedFromId === undefined || nestedThread?.forkedFromId === direct.expectedForkedFromId)) {
1429
+ && (direct.expectedForkedFromId === undefined || nestedThread?.forkedFromId === direct.expectedForkedFromId)
1430
+ && (direct.expectedThreadSource === undefined || nestedThread?.threadSource === direct.expectedThreadSource)) {
1249
1431
  direct.awaitingStart = false;
1250
1432
  direct.threadIds.add(threadId);
1251
1433
  }
1434
+ const tagged = message.method === "thread/started" && typeof nestedThread?.threadSource === "string"
1435
+ ? [...this.stockTargetBuilds.values()].find((candidate) => candidate.expectedThreadSource === nestedThread.threadSource)
1436
+ : undefined;
1437
+ if (tagged) {
1438
+ tagged.awaitingStart = false;
1439
+ tagged.threadIds.add(threadId);
1440
+ }
1252
1441
  const pendingFork = message.method === "thread/started"
1253
1442
  ? [...this.stockTargetBuilds.values()].find((candidate) => candidate.awaitingStart
1254
1443
  && candidate.expectedForkedFromId !== undefined
@@ -1260,7 +1449,7 @@ export class CrossProviderForks {
1260
1449
  }
1261
1450
  const build = direct?.threadIds.has(threadId)
1262
1451
  ? direct
1263
- : pendingFork ?? [...this.stockTargetBuilds.values()].find((candidate) => candidate.threadIds.has(threadId));
1452
+ : tagged ?? pendingFork ?? [...this.stockTargetBuilds.values()].find((candidate) => candidate.threadIds.has(threadId));
1264
1453
  if (!build)
1265
1454
  return false;
1266
1455
  if (!build.threadIds.has(threadId))
@@ -1365,7 +1554,10 @@ export class CrossProviderForks {
1365
1554
  this.store.clearPending(threadId);
1366
1555
  this.store.clearOverlay(threadId);
1367
1556
  }
1368
- close() { this.store.close(); }
1557
+ close() {
1558
+ this.lineage.close();
1559
+ this.store.close();
1560
+ }
1369
1561
  async seedProviderSwitch(threadId, stock) {
1370
1562
  let resolved = this.epochs.resolve(threadId);
1371
1563
  const pending = this.store.getPending(threadId);
@@ -1387,12 +1579,7 @@ export class CrossProviderForks {
1387
1579
  if (resolved.epoch.provider !== pending.sourceProvider) {
1388
1580
  throw new Error("Provider switch source changed before it could be staged.");
1389
1581
  }
1390
- const staged = this.store.stageProviderSwitch({
1391
- pending: { ...pending, expectedEpochId: resolved.epoch.id },
1392
- expectedEpochId: resolved.epoch.id,
1393
- });
1394
- if (!staged)
1395
- throw new Error("Provider switch lost its current provider epoch.");
1582
+ this.store.setPending({ ...pending, expectedEpochId: resolved.epoch.id });
1396
1583
  return this.epochs.resolve(threadId);
1397
1584
  }
1398
1585
  async currentBackendTurns(resolved, stock) {
@@ -1407,39 +1594,183 @@ export class CrossProviderForks {
1407
1594
  });
1408
1595
  return read.thread.turns;
1409
1596
  }
1597
+ async hydrate(resolved, stock) {
1598
+ if (resolved.epoch.backendThreadId.startsWith("ccodex-provisional:"))
1599
+ return resolved;
1600
+ if (resolved.epoch.provider === "claude") {
1601
+ if (this.legacyMirror && (typeof this.claude.readThread !== "function"
1602
+ || typeof this.claude.currentThreadSettings !== "function"))
1603
+ return resolved;
1604
+ const thread = this.claude.readThread(resolved.epoch.backendThreadId, false).thread;
1605
+ const settings = this.claude.currentThreadSettings(resolved.epoch.backendThreadId);
1606
+ return {
1607
+ logical: {
1608
+ ...resolved.logical,
1609
+ thread: this.epochs.projectThread(resolved.logical.publicThreadId, thread, false),
1610
+ },
1611
+ epoch: {
1612
+ ...resolved.epoch,
1613
+ model: settings.model,
1614
+ settings: settings,
1615
+ },
1616
+ };
1617
+ }
1618
+ if (!stock)
1619
+ throw new Error("Stock provider metadata requires the daemon stock connection.");
1620
+ const [read, resume] = await Promise.all([
1621
+ stock.request("thread/read", {
1622
+ threadId: resolved.epoch.backendThreadId,
1623
+ includeTurns: false,
1624
+ }),
1625
+ stock.request("thread/resume", {
1626
+ threadId: resolved.epoch.backendThreadId,
1627
+ excludeTurns: true,
1628
+ }),
1629
+ ]);
1630
+ return {
1631
+ logical: {
1632
+ ...resolved.logical,
1633
+ thread: this.epochs.projectThread(resolved.logical.publicThreadId, read.thread, false),
1634
+ },
1635
+ epoch: {
1636
+ ...resolved.epoch,
1637
+ model: resume.model,
1638
+ settings: resume,
1639
+ },
1640
+ };
1641
+ }
1642
+ async hydrateEpoch(epochId, stock) {
1643
+ const configured = this.epochs.configuredEpoch(epochId);
1644
+ const boundary = this.lineage.getEpoch(epochId);
1645
+ if (!boundary)
1646
+ throw new Error(`Unknown provider epoch '${epochId}'.`);
1647
+ const settings = boundary.provider === "claude"
1648
+ ? typeof this.claude.currentThreadSettings === "function"
1649
+ ? this.claude.currentThreadSettings(boundary.backendThreadId)
1650
+ : configured?.settings ?? {}
1651
+ : await stock.request("thread/resume", {
1652
+ threadId: boundary.backendThreadId,
1653
+ excludeTurns: true,
1654
+ });
1655
+ return {
1656
+ id: boundary.epochId,
1657
+ publicThreadId: boundary.publicThreadId,
1658
+ ordinal: boundary.ordinal,
1659
+ provider: boundary.provider,
1660
+ backendThreadId: boundary.backendThreadId,
1661
+ model: typeof settings.model === "string" ? settings.model : "",
1662
+ settings,
1663
+ state: boundary.state,
1664
+ createdAt: 0,
1665
+ archivePending: boundary.archivePending,
1666
+ deleteDone: boundary.deleteDone,
1667
+ };
1668
+ }
1669
+ async historicalTurns(publicThreadId, stock) {
1670
+ const loaded = new Map();
1671
+ const result = [];
1672
+ for (const segment of this.lineage.listSegments(publicThreadId)) {
1673
+ if (segment.kind === "synthetic") {
1674
+ result.push({
1675
+ publicTurnId: segment.publicTurnId,
1676
+ turn: projectItemIds(segment.turn, segment.publicTurnId),
1677
+ kind: "migrationCompact",
1678
+ });
1679
+ continue;
1680
+ }
1681
+ const epoch = this.lineage.getEpoch(segment.epochId);
1682
+ if (!epoch)
1683
+ throw new Error(`Provider epoch '${segment.epochId}' is missing from lineage.`);
1684
+ let turns = loaded.get(epoch.epochId);
1685
+ if (!turns) {
1686
+ turns = epoch.provider === "claude"
1687
+ ? this.claude.readThread(epoch.backendThreadId, true).thread.turns
1688
+ : (await stock.request("thread/read", {
1689
+ threadId: epoch.backendThreadId,
1690
+ includeTurns: true,
1691
+ })).thread.turns;
1692
+ loaded.set(epoch.epochId, turns);
1693
+ }
1694
+ const start = turns.findIndex((turn) => turn.id === segment.startTurnId);
1695
+ const end = turns.findIndex((turn) => turn.id === segment.endTurnId);
1696
+ if (start < 0 || end < start) {
1697
+ throw new Error(`Provider transcript no longer contains lineage boundary '${segment.startTurnId}'..'${segment.endTurnId}'.`);
1698
+ }
1699
+ result.push(...turns.slice(start, end + 1).map((turn) => ({
1700
+ publicTurnId: turn.id,
1701
+ epochId: epoch.epochId,
1702
+ providerTurnId: turn.id,
1703
+ turn: projectItemIds(turn, epoch.epochId),
1704
+ kind: "provider",
1705
+ })));
1706
+ }
1707
+ return result;
1708
+ }
1709
+ async visibleTurns(publicThreadId, currentBackendTurns, stock) {
1710
+ const currentEpochId = this.epochs.resolve(publicThreadId)?.epoch.id;
1711
+ if (!currentEpochId)
1712
+ throw new Error(`Unknown logical thread '${publicThreadId}'.`);
1713
+ return [
1714
+ ...(await this.historicalTurns(publicThreadId, stock)).map((entry) => entry.turn),
1715
+ ...currentBackendTurns.map((turn) => projectItemIds(turn, currentEpochId)),
1716
+ ];
1717
+ }
1718
+ async snapshotTurns(publicThreadId, currentBackendTurns, stock) {
1719
+ const resolved = this.epochs.resolve(publicThreadId);
1720
+ if (!resolved)
1721
+ throw new Error(`Unknown logical thread '${publicThreadId}'.`);
1722
+ return [
1723
+ ...await this.historicalTurns(publicThreadId, stock),
1724
+ ...currentBackendTurns.map((turn) => ({
1725
+ publicTurnId: turn.id,
1726
+ epochId: resolved.epoch.id,
1727
+ providerTurnId: turn.id,
1728
+ turn: projectItemIds(turn, resolved.epoch.id),
1729
+ kind: "provider",
1730
+ })),
1731
+ ];
1732
+ }
1410
1733
  patchLogicalThread(publicThreadId, patch) {
1734
+ if (!this.legacyMirror)
1735
+ return;
1411
1736
  const logical = this.store.getLogicalThread(publicThreadId);
1412
1737
  if (!logical || !this.store.updateLogicalThread(publicThreadId, logical.revision, { ...logical.thread, ...patch, id: publicThreadId, turns: [] }))
1413
1738
  throw new Error(`Logical thread '${publicThreadId}' changed while metadata was being updated.`);
1414
1739
  }
1415
1740
  updateLogicalEpochFromParams(resolved, params) {
1416
- const settings = { ...params };
1417
- delete settings.threadId;
1418
- delete settings.input;
1419
- delete settings.clientUserMessageId;
1420
- const model = typeof params.model === "string" ? params.model : undefined;
1421
- if (!this.store.updateCurrentEpoch(resolved.logical.publicThreadId, resolved.epoch.id, {
1422
- ...(model ? { model } : {}),
1423
- settings,
1424
- }))
1425
- throw new Error("Current provider epoch changed while settings were being applied.");
1741
+ // The provider request above has already updated the native provider store.
1742
+ // Lineage deliberately does not cache provider-owned settings.
1743
+ void resolved;
1744
+ void params;
1426
1745
  }
1427
1746
  async deleteLogicalThread(publicThreadId, clientStock) {
1428
1747
  const stock = this.daemonStock ?? clientStock;
1429
1748
  this.store.cancelProviderSwitches(publicThreadId, "Task deleted.");
1430
- const mappings = this.store.listBackendMappings()
1431
- .filter((mapping) => mapping.publicThreadId === publicThreadId
1432
- && !mapping.backendThreadId.startsWith("ccodex-provisional:"));
1433
- for (const mapping of mappings)
1434
- this.subscriptions?.suppress(mapping.backendThreadId);
1435
- this.store.deleteLogicalThread(publicThreadId);
1436
- this.subscriptions?.threadDeleted(publicThreadId);
1437
- await Promise.allSettled(mappings.map((mapping) => mapping.provider === "claude"
1438
- ? this.claude.deleteThread(mapping.backendThreadId)
1439
- : stock.request("thread/delete", { threadId: mapping.backendThreadId })));
1440
- for (const mapping of mappings) {
1441
- this.subscriptions?.unaliasThread(mapping.backendThreadId);
1442
- }
1749
+ const epochs = this.lineage.taskEpochs(publicThreadId)
1750
+ .filter((epoch) => !epoch.backendThreadId.startsWith("ccodex-provisional:")
1751
+ && this.lineage.epochReferenceCount(epoch.epochId) <= 1);
1752
+ for (const epoch of epochs)
1753
+ this.subscriptions?.suppress(epoch.backendThreadId);
1754
+ for (const epoch of epochs) {
1755
+ if (epoch.deleteDone)
1756
+ continue;
1757
+ if (epoch.provider === "claude")
1758
+ await this.claude.deleteThread(epoch.backendThreadId);
1759
+ else
1760
+ await stock.request("thread/delete", { threadId: epoch.backendThreadId });
1761
+ this.lineage.markEpochDeleted(epoch.epochId);
1762
+ if (this.legacyMirror)
1763
+ this.store.markEpochDeleted(epoch.epochId);
1764
+ }
1765
+ this.store.addRemoteCatalogTombstone(publicThreadId);
1766
+ if (!this.lineage.deleteTask(publicThreadId)) {
1767
+ throw new Error(`Logical task '${publicThreadId}' disappeared during deletion.`);
1768
+ }
1769
+ if (this.legacyMirror)
1770
+ this.store.deleteLogicalThread(publicThreadId);
1771
+ for (const epoch of epochs)
1772
+ this.subscriptions?.unaliasThread(epoch.backendThreadId);
1773
+ this.subscriptions?.publicThreadDeleted(publicThreadId);
1443
1774
  }
1444
1775
  async providerSwitchSource(job, stock, _connectionId) {
1445
1776
  const resolved = this.epochs.resolve(job.publicThreadId);
@@ -1451,7 +1782,7 @@ export class CrossProviderForks {
1451
1782
  return {
1452
1783
  thread: source.thread,
1453
1784
  backendTurns: source.turns,
1454
- turns: this.epochs.visibleTurns(job.publicThreadId, source.turns),
1785
+ turns: await this.visibleTurns(job.publicThreadId, source.turns, stock),
1455
1786
  settings: source.settings,
1456
1787
  };
1457
1788
  }
@@ -1468,7 +1799,7 @@ export class CrossProviderForks {
1468
1799
  return {
1469
1800
  thread: read.thread,
1470
1801
  backendTurns: read.thread.turns,
1471
- turns: this.epochs.visibleTurns(job.publicThreadId, read.thread.turns),
1802
+ turns: await this.visibleTurns(job.publicThreadId, read.thread.turns, stock),
1472
1803
  settings: resume,
1473
1804
  };
1474
1805
  }
@@ -1476,6 +1807,8 @@ export class CrossProviderForks {
1476
1807
  const source = await this.providerSwitchSource(job, stock, connectionId);
1477
1808
  const summary = job.summary
1478
1809
  ?? await this.providerSwitchSummary(job, source.turns, stock, connectionId);
1810
+ const cwd = job.turnParams.cwd ?? job.settings.cwd ?? source.thread.cwd;
1811
+ const permissionSettings = explicitPermissions(source.settings, job.settings, job.turnParams);
1479
1812
  const completedAt = Math.floor(Date.now() / 1_000);
1480
1813
  const compactTurn = {
1481
1814
  ...job.compactionTurn,
@@ -1489,22 +1822,23 @@ export class CrossProviderForks {
1489
1822
  source,
1490
1823
  summary,
1491
1824
  sourceTurns: [
1492
- ...this.epochs.snapshotTurns(job.publicThreadId, source.backendTurns),
1825
+ ...await this.snapshotTurns(job.publicThreadId, source.backendTurns, stock),
1493
1826
  { publicTurnId: compactTurn.id, turn: compactTurn, kind: "migrationCompact" },
1494
1827
  ],
1495
1828
  developerInstructions: handoffInstructions(source.developerInstructions, summary),
1496
1829
  targetSettings: {
1497
- ...source.settings,
1498
- ...job.settings,
1830
+ ...canonicalSettings(source.settings, job.settings, job.turnParams, cwd),
1499
1831
  model: job.targetModel,
1500
1832
  modelProvider: job.targetProvider === "claude" ? "claude" : "openai",
1501
1833
  },
1834
+ permissionSettings,
1502
1835
  };
1503
1836
  }
1504
1837
  async providerSwitchSummary(job, turns, stock, connectionId) {
1505
- const resolved = this.epochs.resolve(job.publicThreadId);
1506
- if (!resolved)
1838
+ const unresolved = this.epochs.resolve(job.publicThreadId);
1839
+ if (!unresolved)
1507
1840
  throw new Error(`Unknown logical thread '${job.publicThreadId}'.`);
1841
+ const resolved = await this.hydrate(unresolved, stock);
1508
1842
  if (resolved.epoch.provider === "claude") {
1509
1843
  const hidden = await this.claude.forkThread({
1510
1844
  threadId: resolved.epoch.backendThreadId,
@@ -1528,69 +1862,156 @@ export class CrossProviderForks {
1528
1862
  }
1529
1863
  return (await this.summarizeStock({ threadId: resolved.epoch.backendThreadId }, turns, resolved.epoch.model, stock, connectionId)).summary;
1530
1864
  }
1531
- targetThreadStart(job, source, developerInstructions) {
1865
+ targetThreadStart(job, source, developerInstructions, permissionSettings) {
1532
1866
  const turn = job.turnParams;
1533
1867
  const settings = job.settings;
1534
- const sandbox = turn.sandboxPolicy ?? settings.sandboxPolicy;
1535
1868
  return {
1536
1869
  model: job.targetModel,
1537
1870
  modelProvider: job.targetProvider === "claude" ? "claude" : "openai",
1538
1871
  cwd: turn.cwd ?? settings.cwd ?? source.cwd,
1539
1872
  developerInstructions,
1873
+ ...permissionSettings,
1540
1874
  ...(turn.serviceTier !== undefined
1541
1875
  ? { serviceTier: turn.serviceTier }
1542
1876
  : settings.serviceTier !== undefined ? { serviceTier: settings.serviceTier } : {}),
1543
- ...(turn.approvalPolicy !== undefined
1544
- ? { approvalPolicy: turn.approvalPolicy }
1545
- : settings.approvalPolicy !== undefined ? { approvalPolicy: settings.approvalPolicy } : {}),
1546
- ...(turn.approvalsReviewer !== undefined
1547
- ? { approvalsReviewer: turn.approvalsReviewer }
1548
- : settings.approvalsReviewer !== undefined ? { approvalsReviewer: settings.approvalsReviewer } : {}),
1549
- ...(turn.permissions !== undefined
1550
- ? { permissions: turn.permissions }
1551
- : settings.permissions !== undefined ? { permissions: settings.permissions } : {}),
1552
- ...(sandbox && turn.permissions === undefined && settings.permissions === undefined
1553
- ? { sandbox: sandboxMode(sandbox) }
1554
- : {}),
1555
1877
  ephemeral: false,
1556
1878
  threadSource: source.threadSource,
1557
1879
  };
1558
1880
  }
1881
+ async finalizeProviderSwitch(job, target, settings, sourceTurns, stock) {
1882
+ return this.withAdminLock(job.publicThreadId, async () => {
1883
+ const source = this.epochs.resolve(job.publicThreadId);
1884
+ if (!source || source.epoch.id !== job.expectedEpochId) {
1885
+ throw new Error("Provider switch source changed before final cutover.");
1886
+ }
1887
+ const sourceThread = source.epoch.provider === "claude"
1888
+ ? this.claude.readThread(source.epoch.backendThreadId, false).thread
1889
+ : (await stock.request("thread/read", {
1890
+ threadId: source.epoch.backendThreadId,
1891
+ includeTurns: false,
1892
+ })).thread;
1893
+ let confirmed = target;
1894
+ if (sourceThread.name !== null && target.name !== sourceThread.name) {
1895
+ if (job.targetProvider === "claude") {
1896
+ await this.claude.setThreadName({ threadId: target.id, name: sourceThread.name });
1897
+ }
1898
+ else {
1899
+ await stock.request("thread/name/set", { threadId: target.id, name: sourceThread.name });
1900
+ }
1901
+ confirmed = { ...target, name: sourceThread.name };
1902
+ }
1903
+ return this.commitProviderSwitch(job, confirmed, settings, sourceTurns);
1904
+ });
1905
+ }
1559
1906
  commitProviderSwitch(job, target, settings, sourceTurns) {
1560
- const logical = this.store.getLogicalThread(job.publicThreadId);
1561
- if (!logical)
1562
- throw new Error(`Unknown logical thread '${job.publicThreadId}'.`);
1563
- const publicThread = {
1564
- ...target,
1565
- id: job.publicThreadId,
1566
- sessionId: logical.thread.sessionId,
1567
- forkedFromId: logical.thread.forkedFromId,
1568
- parentThreadId: logical.thread.parentThreadId,
1569
- createdAt: logical.thread.createdAt,
1570
- name: logical.thread.name,
1571
- turns: [],
1907
+ const targetEpochId = uuidv7();
1908
+ const sourceProviderTurns = sourceTurns.filter((turn) => turn.kind === "provider" && turn.epochId === job.expectedEpochId && turn.providerTurnId);
1909
+ const firstSourceTurn = sourceProviderTurns[0]?.providerTurnId;
1910
+ const lastSourceTurn = sourceProviderTurns.at(-1)?.providerTurnId;
1911
+ if (!firstSourceTurn || !lastSourceTurn) {
1912
+ throw new Error("Provider switch source has no provider-backed lineage boundary.");
1913
+ }
1914
+ const migrationCompact = sourceTurns.at(-1);
1915
+ if (migrationCompact?.kind !== "migrationCompact"
1916
+ || migrationCompact.publicTurnId !== job.compactionTurn.id) {
1917
+ throw new Error("Provider switch source is missing its current migration-compaction boundary.");
1918
+ }
1919
+ const lineageCommitted = this.lineage.commitEpoch(job.publicThreadId, job.expectedEpochId, job.expectedThreadRevision, { startTurnId: firstSourceTurn, endTurnId: lastSourceTurn }, {
1920
+ epochId: targetEpochId,
1921
+ provider: job.targetProvider,
1922
+ backendThreadId: target.id,
1923
+ }, [{ publicTurnId: migrationCompact.publicTurnId, turn: migrationCompact.turn }]);
1924
+ if (!lineageCommitted)
1925
+ throw new Error("Provider switch lost its lineage CAS during commit.");
1926
+ if (this.legacyMirror) {
1927
+ const task = this.lineage.getTask(job.publicThreadId);
1928
+ const identity = this.epochs.resolve(job.publicThreadId).logical.thread;
1929
+ if (!this.store.commitProviderSwitch({
1930
+ jobId: job.id,
1931
+ targetEpoch: {
1932
+ id: targetEpochId,
1933
+ provider: job.targetProvider,
1934
+ backendThreadId: target.id,
1935
+ model: job.targetModel,
1936
+ settings,
1937
+ },
1938
+ sourceTurns,
1939
+ thread: { ...target, ...identity, id: task.publicThreadId, name: target.name, turns: [] },
1940
+ }))
1941
+ throw new Error("Legacy provider-switch mirror failed to settle.");
1942
+ }
1943
+ else if (!this.store.completeProviderSwitch(job.id)) {
1944
+ throw new Error("Provider switch committed lineage but failed to settle its operation journal.");
1945
+ }
1946
+ const sourceBoundary = this.lineage.getEpoch(job.expectedEpochId);
1947
+ const source = {
1948
+ id: sourceBoundary.epochId,
1949
+ publicThreadId: sourceBoundary.publicThreadId,
1950
+ ordinal: sourceBoundary.ordinal,
1951
+ provider: sourceBoundary.provider,
1952
+ backendThreadId: sourceBoundary.backendThreadId,
1953
+ model: "",
1954
+ settings: {},
1955
+ state: sourceBoundary.state,
1956
+ createdAt: 0,
1957
+ archivePending: sourceBoundary.archivePending,
1958
+ deleteDone: sourceBoundary.deleteDone,
1572
1959
  };
1573
- const committed = this.store.commitProviderSwitch({
1574
- jobId: job.id,
1575
- targetEpoch: {
1576
- id: uuidv7(),
1577
- provider: job.targetProvider,
1578
- backendThreadId: target.id,
1579
- model: job.targetModel,
1580
- settings,
1581
- },
1582
- sourceTurns,
1583
- thread: publicThread,
1584
- });
1585
- if (!committed)
1586
- throw new Error("Provider switch lost its atomic commit boundary.");
1587
- const source = this.store.getEpoch(job.expectedEpochId);
1588
1960
  this.subscriptions?.suppress(source.backendThreadId);
1589
- this.subscriptions?.revealAs(target.id, job.publicThreadId);
1961
+ this.subscriptions?.revealAs(target.id, job.publicThreadId, targetEpochId);
1962
+ this.subscriptions?.emitPublic(job.publicThreadId, "thread/started", {
1963
+ thread: this.epochs.projectThread(job.publicThreadId, projectItemIds(target, targetEpochId), false),
1964
+ });
1965
+ this.subscriptions?.emitPublic(job.publicThreadId, "thread/name/updated", {
1966
+ threadId: job.publicThreadId,
1967
+ ...(target.name === null ? {} : { threadName: target.name }),
1968
+ });
1590
1969
  this.subscriptions?.emitPublic(job.publicThreadId, "thread/settings/updated", {
1591
1970
  threadId: job.publicThreadId,
1592
1971
  threadSettings: settings,
1593
1972
  });
1973
+ return source;
1974
+ }
1975
+ async archiveSealedStockEpoch(epoch, stock) {
1976
+ if (epoch.provider !== "stock" || !epoch.archivePending)
1977
+ return;
1978
+ await stock.request("thread/archive", { threadId: epoch.backendThreadId });
1979
+ this.lineage.markStockArchived(epoch.id);
1980
+ if (this.legacyMirror)
1981
+ this.store.markStockArchived(epoch.id);
1982
+ }
1983
+ async settlePendingStockArchives(stock) {
1984
+ for (const boundary of this.lineage.pendingStockArchives()) {
1985
+ await this.archiveSealedStockEpoch({
1986
+ id: boundary.epochId,
1987
+ publicThreadId: boundary.publicThreadId,
1988
+ ordinal: boundary.ordinal,
1989
+ provider: boundary.provider,
1990
+ backendThreadId: boundary.backendThreadId,
1991
+ model: "",
1992
+ settings: {},
1993
+ state: boundary.state,
1994
+ createdAt: 0,
1995
+ archivePending: boundary.archivePending,
1996
+ deleteDone: boundary.deleteDone,
1997
+ }, stock).catch(() => undefined);
1998
+ }
1999
+ }
2000
+ async withAdminLock(threadId, action) {
2001
+ const previous = this.adminTails.get(threadId) ?? Promise.resolve();
2002
+ let release;
2003
+ const gate = new Promise((resolve) => { release = resolve; });
2004
+ const tail = previous.then(() => gate);
2005
+ this.adminTails.set(threadId, tail);
2006
+ await previous;
2007
+ try {
2008
+ return await action();
2009
+ }
2010
+ finally {
2011
+ release();
2012
+ if (this.adminTails.get(threadId) === tail)
2013
+ this.adminTails.delete(threadId);
2014
+ }
1594
2015
  }
1595
2016
  async summarizeStock(params, inheritedTurns, sourceModel, stock, connectionId) {
1596
2017
  const build = {