@gkorepanov/ccodex 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +141 -63
  2. package/assets/ccodex/goals/continuation.md +1 -0
  3. package/compatibility.json +5 -6
  4. package/config.example.toml +19 -4
  5. package/dist/claude/asyncQueue.d.ts +3 -0
  6. package/dist/claude/asyncQueue.js +13 -2
  7. package/dist/claude/asyncQueue.js.map +1 -1
  8. package/dist/claude/commandActions.d.ts +6 -0
  9. package/dist/claude/commandActions.js +57 -0
  10. package/dist/claude/commandActions.js.map +1 -0
  11. package/dist/claude/compactCommand.d.ts +2 -0
  12. package/dist/claude/compactCommand.js +7 -0
  13. package/dist/claude/compactCommand.js.map +1 -0
  14. package/dist/claude/goalTools.d.ts +15 -0
  15. package/dist/claude/goalTools.js +128 -0
  16. package/dist/claude/goalTools.js.map +1 -0
  17. package/dist/claude/hookMapper.d.ts +17 -4
  18. package/dist/claude/hookMapper.js +7 -5
  19. package/dist/claude/hookMapper.js.map +1 -1
  20. package/dist/claude/inputMapper.d.ts +1 -0
  21. package/dist/claude/inputMapper.js +1 -0
  22. package/dist/claude/inputMapper.js.map +1 -1
  23. package/dist/claude/rateLimits.d.ts +72 -0
  24. package/dist/claude/rateLimits.js +330 -0
  25. package/dist/claude/rateLimits.js.map +1 -0
  26. package/dist/claude/responseItemValidation.d.ts +2 -0
  27. package/dist/claude/responseItemValidation.js +40 -0
  28. package/dist/claude/responseItemValidation.js.map +1 -0
  29. package/dist/claude/resultClassifier.d.ts +8 -2
  30. package/dist/claude/resultClassifier.js +9 -1
  31. package/dist/claude/resultClassifier.js.map +1 -1
  32. package/dist/claude/service.d.ts +116 -29
  33. package/dist/claude/service.js +1337 -680
  34. package/dist/claude/service.js.map +1 -1
  35. package/dist/claude/session/backgroundOutput.d.ts +2 -0
  36. package/dist/claude/session/backgroundOutput.js +29 -0
  37. package/dist/claude/session/backgroundOutput.js.map +1 -0
  38. package/dist/claude/session/commands.d.ts +862 -0
  39. package/dist/claude/session/commands.js +2 -0
  40. package/dist/claude/session/commands.js.map +1 -0
  41. package/dist/claude/session/goalState.d.ts +49 -0
  42. package/dist/claude/session/goalState.js +407 -0
  43. package/dist/claude/session/goalState.js.map +1 -0
  44. package/dist/claude/session/mailbox.d.ts +52 -0
  45. package/dist/claude/session/mailbox.js +190 -0
  46. package/dist/claude/session/mailbox.js.map +1 -0
  47. package/dist/claude/session/outputAdapter.d.ts +11 -0
  48. package/dist/claude/session/outputAdapter.js +31 -0
  49. package/dist/claude/session/outputAdapter.js.map +1 -0
  50. package/dist/claude/session/permissionUpdates.d.ts +4 -0
  51. package/dist/claude/session/permissionUpdates.js +17 -0
  52. package/dist/claude/session/permissionUpdates.js.map +1 -0
  53. package/dist/claude/session/providerFacts.d.ts +47 -0
  54. package/dist/claude/session/providerFacts.js +84 -0
  55. package/dist/claude/session/providerFacts.js.map +1 -0
  56. package/dist/claude/session/providerRuntime.d.ts +1 -0
  57. package/dist/claude/session/providerRuntime.js +2 -0
  58. package/dist/claude/session/providerRuntime.js.map +1 -0
  59. package/dist/claude/session/providerRuntimeFactory.d.ts +49 -0
  60. package/dist/claude/session/providerRuntimeFactory.js +122 -0
  61. package/dist/claude/session/providerRuntimeFactory.js.map +1 -0
  62. package/dist/claude/session/repository.d.ts +57 -0
  63. package/dist/claude/session/repository.js +161 -0
  64. package/dist/claude/session/repository.js.map +1 -0
  65. package/dist/claude/session/runtime.d.ts +46 -0
  66. package/dist/claude/session/runtime.js +140 -0
  67. package/dist/claude/session/runtime.js.map +1 -0
  68. package/dist/claude/session/scopeState.d.ts +61 -0
  69. package/dist/claude/session/scopeState.js +74 -0
  70. package/dist/claude/session/scopeState.js.map +1 -0
  71. package/dist/claude/session/session.d.ts +285 -0
  72. package/dist/claude/session/session.js +6778 -0
  73. package/dist/claude/session/session.js.map +1 -0
  74. package/dist/claude/session/shellRunner.d.ts +13 -0
  75. package/dist/claude/session/shellRunner.js +103 -0
  76. package/dist/claude/session/shellRunner.js.map +1 -0
  77. package/dist/claude/session/usage.d.ts +2 -0
  78. package/dist/claude/session/usage.js +10 -0
  79. package/dist/claude/session/usage.js.map +1 -0
  80. package/dist/claude/sessionRegistry.d.ts +26 -0
  81. package/dist/claude/sessionRegistry.js +118 -0
  82. package/dist/claude/sessionRegistry.js.map +1 -0
  83. package/dist/claude/shellSupervisor.d.ts +1 -0
  84. package/dist/claude/shellSupervisor.js +69 -0
  85. package/dist/claude/shellSupervisor.js.map +1 -0
  86. package/dist/claude/statusCommand.d.ts +21 -0
  87. package/dist/claude/statusCommand.js +97 -0
  88. package/dist/claude/statusCommand.js.map +1 -0
  89. package/dist/claude/toolMapper.d.ts +9 -1
  90. package/dist/claude/toolMapper.js +98 -18
  91. package/dist/claude/toolMapper.js.map +1 -1
  92. package/dist/claude/transcriptBrancher.d.ts +37 -0
  93. package/dist/claude/transcriptBrancher.js +103 -0
  94. package/dist/claude/transcriptBrancher.js.map +1 -0
  95. package/dist/cli/delegate.js +23 -1
  96. package/dist/cli/delegate.js.map +1 -1
  97. package/dist/cli/main.js +28 -12
  98. package/dist/cli/main.js.map +1 -1
  99. package/dist/codex/generated/ResponseItemRuntimeSchema.d.ts +8 -0
  100. package/dist/codex/generated/ResponseItemRuntimeSchema.js +7 -0
  101. package/dist/codex/generated/ResponseItemRuntimeSchema.js.map +1 -0
  102. package/dist/compatibility/probe.js +11 -6
  103. package/dist/compatibility/probe.js.map +1 -1
  104. package/dist/config/config.d.ts +12 -0
  105. package/dist/config/config.js +103 -11
  106. package/dist/config/config.js.map +1 -1
  107. package/dist/daemon/daemon.js +91 -43
  108. package/dist/daemon/daemon.js.map +1 -1
  109. package/dist/daemon/ownership.d.ts +15 -0
  110. package/dist/daemon/ownership.js +185 -0
  111. package/dist/daemon/ownership.js.map +1 -0
  112. package/dist/daemon/probe.js +1 -1
  113. package/dist/daemon/supervisor.d.ts +3 -0
  114. package/dist/daemon/supervisor.js +37 -1
  115. package/dist/daemon/supervisor.js.map +1 -1
  116. package/dist/gateway/clientConnection.d.ts +7 -1
  117. package/dist/gateway/clientConnection.js +656 -60
  118. package/dist/gateway/clientConnection.js.map +1 -1
  119. package/dist/gateway/logicalThreadProjection.d.ts +8 -0
  120. package/dist/gateway/logicalThreadProjection.js +59 -0
  121. package/dist/gateway/logicalThreadProjection.js.map +1 -0
  122. package/dist/gateway/remoteRelay.js +0 -1
  123. package/dist/gateway/remoteRelay.js.map +1 -1
  124. package/dist/gateway/server.d.ts +1 -1
  125. package/dist/gateway/server.js +69 -11
  126. package/dist/gateway/server.js.map +1 -1
  127. package/dist/gateway/stockRpc.d.ts +5 -2
  128. package/dist/gateway/stockRpc.js +52 -6
  129. package/dist/gateway/stockRpc.js.map +1 -1
  130. package/dist/gateway/subscriptions.d.ts +19 -3
  131. package/dist/gateway/subscriptions.js +152 -13
  132. package/dist/gateway/subscriptions.js.map +1 -1
  133. package/dist/gateway/threadList.d.ts +7 -2
  134. package/dist/gateway/threadList.js +8 -4
  135. package/dist/gateway/threadList.js.map +1 -1
  136. package/dist/gateway/transientNotice.d.ts +15 -0
  137. package/dist/gateway/transientNotice.js +96 -1
  138. package/dist/gateway/transientNotice.js.map +1 -1
  139. package/dist/handoff/providerEpochs.d.ts +18 -0
  140. package/dist/handoff/providerEpochs.js +77 -0
  141. package/dist/handoff/providerEpochs.js.map +1 -0
  142. package/dist/handoff/service.d.ts +86 -6
  143. package/dist/handoff/service.js +1276 -168
  144. package/dist/handoff/service.js.map +1 -1
  145. package/dist/handoff/store.d.ts +189 -2
  146. package/dist/handoff/store.js +618 -7
  147. package/dist/handoff/store.js.map +1 -1
  148. package/dist/handoff/titleGeneration.d.ts +28 -0
  149. package/dist/handoff/titleGeneration.js +138 -0
  150. package/dist/handoff/titleGeneration.js.map +1 -0
  151. package/dist/management/doctor.d.ts +1 -1
  152. package/dist/management/doctor.js +32 -23
  153. package/dist/management/doctor.js.map +1 -1
  154. package/dist/management/lifecycle.js +14 -2
  155. package/dist/management/lifecycle.js.map +1 -1
  156. package/dist/management/remoteShim.d.ts +10 -0
  157. package/dist/management/remoteShim.js +65 -0
  158. package/dist/management/remoteShim.js.map +1 -0
  159. package/dist/management/setup.d.ts +5 -0
  160. package/dist/management/setup.js +120 -39
  161. package/dist/management/setup.js.map +1 -1
  162. package/dist/observability/metrics.d.ts +2 -0
  163. package/dist/observability/metrics.js +6 -0
  164. package/dist/observability/metrics.js.map +1 -1
  165. package/dist/protocol/errors.d.ts +1 -0
  166. package/dist/protocol/errors.js +4 -1
  167. package/dist/protocol/errors.js.map +1 -1
  168. package/dist/runtime/dependencies.js +3 -3
  169. package/dist/runtime/dependencies.js.map +1 -1
  170. package/dist/runtime/providerAvailability.d.ts +24 -0
  171. package/dist/runtime/providerAvailability.js +107 -0
  172. package/dist/runtime/providerAvailability.js.map +1 -0
  173. package/dist/state/stateCommand.d.ts +21 -0
  174. package/dist/state/stateCommand.js +198 -0
  175. package/dist/state/stateCommand.js.map +1 -0
  176. package/dist/state/stockStateTracker.d.ts +16 -0
  177. package/dist/state/stockStateTracker.js +137 -0
  178. package/dist/state/stockStateTracker.js.map +1 -0
  179. package/dist/store/HybridStore.d.ts +118 -2
  180. package/dist/store/HybridStore.js +23 -1
  181. package/dist/store/HybridStore.js.map +1 -1
  182. package/dist/store/memoryStore.d.ts +57 -7
  183. package/dist/store/memoryStore.js +337 -5
  184. package/dist/store/memoryStore.js.map +1 -1
  185. package/dist/store/sqliteStore.d.ts +30 -4
  186. package/dist/store/sqliteStore.js +395 -42
  187. package/dist/store/sqliteStore.js.map +1 -1
  188. package/install.sh +2 -2
  189. package/{LICENSES.md → legal/LICENSES.md} +2 -2
  190. package/{THIRD_PARTY_NOTICES.md → legal/THIRD_PARTY_NOTICES.md} +177 -148
  191. package/package.json +28 -18
  192. package/vendor/codex/continuation.md +51 -0
  193. package/BUILD_BENCHMARKS.md +0 -27
  194. package/RELEASING.md +0 -98
  195. package/SSH_VERIFY.md +0 -69
  196. package/dist/claude/interactions.d.ts +0 -23
  197. package/dist/claude/interactions.js +0 -77
  198. package/dist/claude/interactions.js.map +0 -1
  199. package/dist/claude/sessionRuntime.d.ts +0 -102
  200. package/dist/claude/sessionRuntime.js +0 -1361
  201. package/dist/claude/sessionRuntime.js.map +0 -1
@@ -1,4 +1,10 @@
1
+ import { DEFAULT_RENAME_PROMPT } from "../config/config.js";
2
+ import { projectRpcToBackendThread, projectRpcToPublicThread } from "../gateway/logicalThreadProjection.js";
1
3
  import { invalidParams } from "../protocol/errors.js";
4
+ import { v7 as uuidv7 } from "uuid";
5
+ import { ProviderEpochs } from "./providerEpochs.js";
6
+ import { normalizedTitlePayload, rewrittenTitlePrompt, titlePrompt } from "./titleGeneration.js";
7
+ export const HANDOFF_DAEMON_CONNECTION_ID = "ccodex-handoff-daemon";
2
8
  const SUMMARY_INSTRUCTIONS = `Create a compact, portable handoff for another coding agent/provider.
3
9
  Return plain text only. Preserve: user goal, important decisions and constraints, repository/cwd, files and symbols touched,
4
10
  commands and tests run, current state, unresolved bugs, and exact next steps. Do not call tools. Do not continue the task.
@@ -26,19 +32,25 @@ function sandboxMode(policy) {
26
32
  return "read-only";
27
33
  return "workspace-write";
28
34
  }
29
- function selectedTurns(thread, lastTurnId) {
30
- if (!lastTurnId)
31
- return thread.turns;
32
- const through = thread.turns.findIndex((turn) => turn.id === lastTurnId);
33
- if (through < 0)
34
- throw invalidParams(`Unknown source turn '${lastTurnId}'.`);
35
- return thread.turns.slice(0, through + 1);
36
- }
37
35
  function agentText(turn) {
38
36
  return turn.items.flatMap((item) => item.type === "agentMessage" ? [item.text] : []).join("\n").trim();
39
37
  }
38
+ function stockTitleContext(turns) {
39
+ return turns.flatMap((turn) => turn.items.flatMap((item) => {
40
+ if (item.type === "userMessage") {
41
+ const text = item.content.flatMap((content) => content.type === "text" ? [content.text] : []).join("\n").trim();
42
+ return text ? [{ type: "message", role: "user", content: [{ type: "input_text", text }] }] : [];
43
+ }
44
+ if (item.type === "agentMessage" && item.text.trim()) {
45
+ return [{ type: "message", role: "assistant", content: [{ type: "output_text", text: item.text }] }];
46
+ }
47
+ return [];
48
+ }));
49
+ }
40
50
  function stagedSettings(base, pending) {
41
51
  const settings = pending.settings;
52
+ const effort = settings.effort === undefined ? base.effort : settings.effort;
53
+ const collaborationMode = settings.collaborationMode ?? base.collaborationMode;
42
54
  return {
43
55
  ...base,
44
56
  ...(settings.cwd !== undefined && settings.cwd !== null ? { cwd: settings.cwd } : {}),
@@ -48,10 +60,16 @@ function stagedSettings(base, pending) {
48
60
  model: pending.targetModel,
49
61
  modelProvider: pending.targetProvider === "claude" ? "claude" : "openai",
50
62
  serviceTier: settings.serviceTier === undefined ? base.serviceTier : settings.serviceTier,
51
- effort: settings.effort === undefined ? base.effort : settings.effort,
63
+ effort,
52
64
  summary: settings.summary === undefined ? base.summary : settings.summary,
53
- collaborationMode: settings.collaborationMode === undefined || settings.collaborationMode === null
54
- ? base.collaborationMode : settings.collaborationMode,
65
+ collaborationMode: {
66
+ ...collaborationMode,
67
+ settings: {
68
+ ...collaborationMode.settings,
69
+ model: pending.targetModel,
70
+ reasoning_effort: effort,
71
+ },
72
+ },
55
73
  personality: settings.personality === undefined ? base.personality : settings.personality,
56
74
  };
57
75
  }
@@ -89,6 +107,17 @@ function pageItems(turns, params) {
89
107
  backwardsCursor: data.length > 0 ? `hyb-overlay-item:${Math.max(0, offset - limit)}` : null,
90
108
  };
91
109
  }
110
+ function messageThreadId(message) {
111
+ if (!message.params || typeof message.params !== "object")
112
+ return undefined;
113
+ const params = message.params;
114
+ const nestedThread = params.thread && typeof params.thread === "object"
115
+ ? params.thread
116
+ : undefined;
117
+ return typeof params.threadId === "string"
118
+ ? params.threadId
119
+ : typeof nestedThread?.id === "string" ? nestedThread.id : undefined;
120
+ }
92
121
  function providerModel(params) {
93
122
  return params.model ?? params.collaborationMode?.settings.model ?? undefined;
94
123
  }
@@ -99,16 +128,708 @@ function forkName(thread, sourceProvider) {
99
128
  export class CrossProviderForks {
100
129
  store;
101
130
  claude;
131
+ renamePrompt;
132
+ epochs;
102
133
  internalStockThreads = new Set();
134
+ suppressedInternalStockThreads = new Set();
103
135
  internalStockTurns = new Map();
136
+ internalStockBuilds = new Map();
104
137
  stockTargetBuilds = new Map();
105
- constructor(store, claude) {
138
+ systemEphemeralThreads = new Map();
139
+ directTitleCandidates = new Map();
140
+ titleTurns = new Map();
141
+ stockServerRequests = new Map();
142
+ stockServerRequestAliases = new Map();
143
+ recentDurableProviders = new Map();
144
+ daemonStock;
145
+ subscriptions;
146
+ jobTail = Promise.resolve();
147
+ constructor(store, claude, renamePrompt = DEFAULT_RENAME_PROMPT) {
106
148
  this.store = store;
107
149
  this.claude = claude;
150
+ this.renamePrompt = renamePrompt;
151
+ this.epochs = new ProviderEpochs(store);
108
152
  }
109
153
  pending(threadId) { return this.store.getPending(threadId); }
154
+ logical(threadId) { return this.epochs.resolve(threadId); }
155
+ hiddenBackendIds(provider) { return this.epochs.hiddenBackendIds(provider); }
156
+ projectThreadCatalog(stockThreads, claudeThreads) {
157
+ const physical = new Map([...stockThreads, ...claudeThreads].map((thread) => [thread.id, thread]));
158
+ const mappings = this.store.listBackendMappings();
159
+ const hidden = new Set(mappings.map((mapping) => mapping.backendThreadId));
160
+ const ordinary = [...physical.values()].filter((thread) => !hidden.has(thread.id));
161
+ const logical = mappings.flatMap((mapping) => {
162
+ if (mapping.state !== "current")
163
+ return [];
164
+ const backend = physical.get(mapping.backendThreadId);
165
+ if (backend)
166
+ return [this.epochs.projectThread(mapping.publicThreadId, backend, false)];
167
+ if (mapping.backendThreadId.startsWith("ccodex-provisional:")) {
168
+ const provisional = this.store.getLogicalThread(mapping.publicThreadId);
169
+ return provisional ? [{ ...provisional.thread, turns: [] }] : [];
170
+ }
171
+ return [];
172
+ });
173
+ return [...ordinary, ...logical];
174
+ }
175
+ projectLoadedThreadIds(stockIds, claudeIds) {
176
+ const loaded = new Set([...stockIds, ...claudeIds]);
177
+ const mappings = this.store.listBackendMappings();
178
+ const hidden = new Set(mappings.map((mapping) => mapping.backendThreadId));
179
+ const ordinary = [...loaded].filter((threadId) => !hidden.has(threadId));
180
+ const logical = mappings.flatMap((mapping) => mapping.state === "current" && loaded.has(mapping.backendThreadId) ? [mapping.publicThreadId] : []);
181
+ return [...new Set([...ordinary, ...logical])];
182
+ }
183
+ projectStockMessage(message) {
184
+ if (!message.method)
185
+ return false;
186
+ const backendThreadId = messageThreadId({ params: message.params });
187
+ if (!backendThreadId)
188
+ return false;
189
+ const epoch = this.store.findEpochByBackend("stock", backendThreadId);
190
+ if (!epoch || epoch.state !== "current" || !this.subscriptions)
191
+ return false;
192
+ if (message.method === "serverRequest/resolved" && message.params && typeof message.params === "object") {
193
+ const params = message.params;
194
+ const providerRequestId = params.requestId;
195
+ const requestId = providerRequestId === undefined
196
+ ? undefined
197
+ : this.stockServerRequestAliases.get(String(providerRequestId));
198
+ if (requestId) {
199
+ this.subscriptions.emit(backendThreadId, message.method, { ...params, requestId });
200
+ this.stockServerRequestAliases.delete(String(providerRequestId));
201
+ this.stockServerRequests.delete(requestId);
202
+ return true;
203
+ }
204
+ }
205
+ if (message.id !== undefined) {
206
+ const requestId = `logical-stock:${uuidv7()}`;
207
+ this.stockServerRequests.set(requestId, message.id);
208
+ this.stockServerRequestAliases.set(String(message.id), requestId);
209
+ if (!this.subscriptions.request(backendThreadId, requestId, message.method, message.params)) {
210
+ this.stockServerRequests.delete(requestId);
211
+ this.stockServerRequestAliases.delete(String(message.id));
212
+ void this.daemonStock?.respond(message.id, { decision: "decline" });
213
+ }
214
+ }
215
+ else {
216
+ this.subscriptions.emit(backendThreadId, message.method, message.params);
217
+ }
218
+ return true;
219
+ }
220
+ ownsStockBackendMessage(message) {
221
+ const backendThreadId = messageThreadId(message);
222
+ return backendThreadId !== undefined && this.store.findEpochByBackend("stock", backendThreadId) !== undefined;
223
+ }
224
+ async resolveStockServerRequest(requestId, result) {
225
+ const stockRequestId = this.stockServerRequests.get(requestId);
226
+ if (stockRequestId === undefined || !this.daemonStock)
227
+ return false;
228
+ this.stockServerRequests.delete(requestId);
229
+ await this.daemonStock.respond(stockRequestId, result);
230
+ return true;
231
+ }
232
+ async requestLogical(method, publicParams, clientStock) {
233
+ const publicThreadId = publicParams.threadId;
234
+ if (typeof publicThreadId !== "string")
235
+ throw invalidParams("Logical thread request has no threadId.");
236
+ const resolved = this.epochs.resolve(publicThreadId);
237
+ if (!resolved)
238
+ throw invalidParams(`Unknown logical thread '${publicThreadId}'.`);
239
+ const owner = { publicThreadId, backendThreadId: resolved.epoch.backendThreadId };
240
+ const params = projectRpcToBackendThread({ params: publicParams }, owner).params;
241
+ if (method === "thread/delete") {
242
+ await this.deleteLogicalThread(publicThreadId, clientStock);
243
+ return { provider: resolved.epoch.provider, result: {} };
244
+ }
245
+ if (resolved.epoch.backendThreadId.startsWith("ccodex-provisional:")) {
246
+ return this.requestProvisionalLogical(method, publicParams, resolved);
247
+ }
248
+ if (resolved.epoch.provider === "stock") {
249
+ const stock = this.daemonStock ?? clientStock;
250
+ if (method === "thread/read" || method === "thread/resume") {
251
+ const result = await stock.request(method, params);
252
+ const resume = method === "thread/resume" ? params : undefined;
253
+ const includeTurns = resume
254
+ ? !resume.excludeTurns
255
+ : params.includeTurns ?? false;
256
+ const backendTurns = resume?.initialTurnsPage && !includeTurns
257
+ ? (await stock.request("thread/read", {
258
+ threadId: resolved.epoch.backendThreadId,
259
+ includeTurns: true,
260
+ })).thread.turns
261
+ : result.thread.turns;
262
+ const initialTurnsPage = resume?.initialTurnsPage
263
+ ? pageTurns(this.epochs.visibleTurns(publicThreadId, backendTurns), {
264
+ threadId: publicThreadId,
265
+ ...resume.initialTurnsPage,
266
+ })
267
+ : "initialTurnsPage" in result ? result.initialTurnsPage : undefined;
268
+ return {
269
+ provider: "stock",
270
+ result: {
271
+ ...result,
272
+ thread: this.epochs.projectThread(publicThreadId, result.thread, includeTurns),
273
+ ...(resume ? { initialTurnsPage } : {}),
274
+ },
275
+ };
276
+ }
277
+ if (method === "thread/turns/list" || method === "thread/items/list") {
278
+ const read = await stock.request("thread/read", {
279
+ threadId: resolved.epoch.backendThreadId,
280
+ includeTurns: true,
281
+ });
282
+ const turns = this.epochs.visibleTurns(publicThreadId, read.thread.turns);
283
+ return {
284
+ provider: "stock",
285
+ result: method === "thread/turns/list"
286
+ ? pageTurns(turns, publicParams)
287
+ : pageItems(turns, publicParams),
288
+ };
289
+ }
290
+ const result = await stock.request(method, params);
291
+ if (method === "thread/settings/update" || method === "turn/start") {
292
+ this.updateLogicalEpochFromParams(resolved, publicParams);
293
+ }
294
+ if (method === "thread/name/set" && typeof publicParams.name === "string") {
295
+ this.patchLogicalThread(publicThreadId, { name: publicParams.name });
296
+ }
297
+ return {
298
+ provider: "stock",
299
+ result: projectRpcToPublicThread({ result }, owner).result,
300
+ };
301
+ }
302
+ const threadId = resolved.epoch.backendThreadId;
303
+ if (method === "thread/read") {
304
+ const includeTurns = params.includeTurns ?? false;
305
+ const thread = this.epochs.projectThread(publicThreadId, this.claude.readThread(threadId, includeTurns).thread, includeTurns);
306
+ return { provider: "claude", result: { thread } };
307
+ }
308
+ if (method === "thread/resume") {
309
+ const resume = params;
310
+ const prepared = await this.claude.prepareResume(resume);
311
+ const response = prepared.response;
312
+ const thread = this.epochs.projectThread(publicThreadId, response.thread, !resume.excludeTurns);
313
+ const backendTurns = resume.initialTurnsPage && resume.excludeTurns
314
+ ? this.claude.readThread(threadId, true).thread.turns
315
+ : response.thread.turns;
316
+ return {
317
+ provider: "claude",
318
+ result: {
319
+ ...response,
320
+ thread,
321
+ ...(resume.initialTurnsPage ? {
322
+ initialTurnsPage: pageTurns(this.epochs.visibleTurns(publicThreadId, backendTurns), {
323
+ threadId: publicThreadId,
324
+ ...resume.initialTurnsPage,
325
+ }),
326
+ } : {}),
327
+ },
328
+ after: async () => prepared.notifyGoalSnapshot((event, eventParams) => {
329
+ this.subscriptions?.emit(threadId, event, eventParams);
330
+ }),
331
+ };
332
+ }
333
+ if (method === "thread/turns/list") {
334
+ const read = this.claude.readThread(threadId, true).thread;
335
+ return {
336
+ provider: "claude",
337
+ result: pageTurns(this.epochs.visibleTurns(publicThreadId, read.turns), publicParams),
338
+ };
339
+ }
340
+ if (method === "thread/items/list") {
341
+ const read = this.claude.readThread(threadId, true).thread;
342
+ return {
343
+ provider: "claude",
344
+ result: pageItems(this.epochs.visibleTurns(publicThreadId, read.turns), publicParams),
345
+ };
346
+ }
347
+ if (method === "thread/settings/update") {
348
+ const result = await this.claude.updateThreadSettings(params);
349
+ this.updateLogicalEpochFromParams(resolved, publicParams);
350
+ return {
351
+ provider: "claude",
352
+ result,
353
+ };
354
+ }
355
+ if (method === "thread/name/set") {
356
+ const result = await this.claude.setThreadName(params);
357
+ if (typeof publicParams.name === "string")
358
+ this.patchLogicalThread(publicThreadId, { name: publicParams.name });
359
+ return { provider: "claude", result };
360
+ }
361
+ if (method === "thread/metadata/update") {
362
+ return {
363
+ provider: "claude",
364
+ result: await this.claude.updateThreadMetadata(params),
365
+ };
366
+ }
367
+ if (method === "thread/archive") {
368
+ return { provider: "claude", result: await this.claude.archiveThread(threadId) };
369
+ }
370
+ if (method === "thread/unarchive") {
371
+ return { provider: "claude", result: await this.claude.unarchiveThread(threadId) };
372
+ }
373
+ if (method === "thread/compact/start") {
374
+ return { provider: "claude", result: await this.claude.compactThread(threadId) };
375
+ }
376
+ if (method === "thread/inject_items") {
377
+ return { provider: "claude", result: await this.claude.injectItems(params) };
378
+ }
379
+ if (method === "thread/shellCommand") {
380
+ return { provider: "claude", result: await this.claude.shellCommand(params) };
381
+ }
382
+ if (method === "thread/backgroundTerminals/clean") {
383
+ return {
384
+ provider: "claude",
385
+ result: await this.claude.cleanBackgroundTerminals(params),
386
+ };
387
+ }
388
+ if (method === "thread/backgroundTerminals/list") {
389
+ return {
390
+ provider: "claude",
391
+ result: await this.claude.listBackgroundTerminals(params),
392
+ };
393
+ }
394
+ if (method === "thread/backgroundTerminals/terminate") {
395
+ return {
396
+ provider: "claude",
397
+ result: await this.claude.terminateBackgroundTerminal(params),
398
+ };
399
+ }
400
+ if (method === "thread/goal/set") {
401
+ const prepared = await this.claude.prepareGoalSet(params);
402
+ return { provider: "claude", result: prepared.response, after: prepared.notify };
403
+ }
404
+ if (method === "thread/goal/get") {
405
+ return { provider: "claude", result: await this.claude.getGoal(threadId) };
406
+ }
407
+ if (method === "thread/goal/clear") {
408
+ const prepared = await this.claude.prepareGoalClear(threadId);
409
+ return { provider: "claude", result: prepared.response, after: prepared.notify };
410
+ }
411
+ if (method === "turn/start") {
412
+ const prepared = await this.claude.prepareTurn(params);
413
+ this.updateLogicalEpochFromParams(resolved, publicParams);
414
+ return {
415
+ provider: "claude",
416
+ result: prepared.response,
417
+ after: async () => { await prepared.announce(); prepared.start(); },
418
+ };
419
+ }
420
+ if (method === "turn/interrupt") {
421
+ await this.claude.interruptTurn(params);
422
+ return { provider: "claude", result: {} };
423
+ }
424
+ if (method === "turn/steer") {
425
+ return { provider: "claude", result: await this.claude.steerTurn(params) };
426
+ }
427
+ throw invalidParams(`Method '${method}' is not implemented for a migrated Claude task yet.`);
428
+ }
429
+ requestProvisionalLogical(method, params, resolved) {
430
+ const turns = this.store.listLogicalTurns(resolved.logical.publicThreadId).map((value) => value.turn);
431
+ const thread = (includeTurns) => ({
432
+ ...resolved.logical.thread,
433
+ id: resolved.logical.publicThreadId,
434
+ turns: includeTurns ? turns : [],
435
+ });
436
+ if (method === "thread/read") {
437
+ return {
438
+ provider: resolved.epoch.provider,
439
+ result: { thread: thread(params.includeTurns ?? false) },
440
+ };
441
+ }
442
+ if (method === "thread/resume") {
443
+ const settings = resolved.epoch.settings;
444
+ return {
445
+ provider: resolved.epoch.provider,
446
+ result: {
447
+ thread: thread(!params.excludeTurns),
448
+ model: resolved.epoch.model,
449
+ modelProvider: resolved.epoch.provider === "claude" ? "claude" : "openai",
450
+ serviceTier: typeof settings.serviceTier === "string" ? settings.serviceTier : null,
451
+ cwd: resolved.logical.thread.cwd,
452
+ runtimeWorkspaceRoots: Array.isArray(settings.runtimeWorkspaceRoots)
453
+ ? settings.runtimeWorkspaceRoots : [],
454
+ instructionSources: Array.isArray(settings.instructionSources) ? settings.instructionSources : [],
455
+ approvalPolicy: settings.approvalPolicy ?? "on-request",
456
+ approvalsReviewer: settings.approvalsReviewer ?? "user",
457
+ sandbox: settings.sandbox ?? settings.sandboxPolicy ?? { type: "readOnly" },
458
+ activePermissionProfile: settings.activePermissionProfile ?? null,
459
+ reasoningEffort: settings.reasoningEffort ?? settings.effort ?? null,
460
+ multiAgentMode: settings.multiAgentMode ?? "explicitRequestOnly",
461
+ initialTurnsPage: null,
462
+ },
463
+ };
464
+ }
465
+ if (method === "thread/turns/list") {
466
+ return {
467
+ provider: resolved.epoch.provider,
468
+ result: pageTurns(turns, params),
469
+ };
470
+ }
471
+ if (method === "thread/items/list") {
472
+ return {
473
+ provider: resolved.epoch.provider,
474
+ result: pageItems(turns, params),
475
+ };
476
+ }
477
+ if (method === "thread/name/set" && typeof params.name === "string") {
478
+ this.patchLogicalThread(resolved.logical.publicThreadId, { name: params.name });
479
+ return { provider: resolved.epoch.provider, result: {} };
480
+ }
481
+ throw invalidParams("This fork is waiting for Codex App to select a completed history boundary.");
482
+ }
483
+ async forkLogical(params, clientStock) {
484
+ const source = this.epochs.resolve(params.threadId);
485
+ if (!source)
486
+ throw invalidParams(`Unknown logical thread '${params.threadId}'.`);
487
+ if (params.model && providerForModel(this.claude, params.model) !== source.epoch.provider) {
488
+ throw invalidParams("Fork is same-provider only. Change the model and send a message to switch provider.");
489
+ }
490
+ const currentTurns = await this.currentBackendTurns(source, this.daemonStock ?? clientStock);
491
+ const visible = this.epochs.snapshotTurns(params.threadId, currentTurns);
492
+ const targetId = uuidv7();
493
+ const placeholderId = `ccodex-provisional:${uuidv7()}`;
494
+ const now = Math.floor(Date.now() / 1_000);
495
+ const thread = {
496
+ ...source.logical.thread,
497
+ id: targetId,
498
+ sessionId: source.logical.thread.sessionId,
499
+ forkedFromId: params.threadId,
500
+ parentThreadId: null,
501
+ createdAt: now,
502
+ updatedAt: now,
503
+ recencyAt: now,
504
+ status: { type: "notLoaded" },
505
+ name: source.logical.thread.name ? `${source.logical.thread.name} (fork)` : null,
506
+ turns: visible.map((turn) => turn.turn),
507
+ };
508
+ const target = this.store.createLogicalThread({
509
+ thread,
510
+ epoch: {
511
+ id: uuidv7(),
512
+ provider: source.epoch.provider,
513
+ backendThreadId: placeholderId,
514
+ model: source.epoch.model,
515
+ settings: source.epoch.settings,
516
+ },
517
+ });
518
+ if (visible.length > 0 && !this.store.replaceLogicalTurns(targetId, target.revision, visible)) {
519
+ throw new Error("Failed to persist provisional logical fork history.");
520
+ }
521
+ const provisional = this.epochs.resolve(targetId);
522
+ if (!this.store.createForkSelection({
523
+ targetPublicThreadId: targetId,
524
+ sourcePublicThreadId: params.threadId,
525
+ provisionalEpochId: provisional.epoch.id,
526
+ }))
527
+ throw new Error("Failed to persist logical fork selection.");
528
+ const settings = source.epoch.settings;
529
+ const response = {
530
+ thread: { ...thread, turns: params.excludeTurns ? [] : thread.turns },
531
+ model: source.epoch.model,
532
+ modelProvider: source.epoch.provider === "claude" ? "claude" : "openai",
533
+ serviceTier: typeof settings.serviceTier === "string" ? settings.serviceTier : null,
534
+ cwd: thread.cwd,
535
+ runtimeWorkspaceRoots: Array.isArray(settings.runtimeWorkspaceRoots)
536
+ ? settings.runtimeWorkspaceRoots : [],
537
+ instructionSources: Array.isArray(settings.instructionSources)
538
+ ? settings.instructionSources : [],
539
+ approvalPolicy: (settings.approvalPolicy ?? "on-request"),
540
+ approvalsReviewer: (settings.approvalsReviewer ?? "user"),
541
+ sandbox: (settings.sandbox ?? settings.sandboxPolicy ?? { type: "readOnly" }),
542
+ activePermissionProfile: (settings.activePermissionProfile ?? null),
543
+ reasoningEffort: (settings.reasoningEffort ?? settings.effort ?? null),
544
+ multiAgentMode: "explicitRequestOnly",
545
+ };
546
+ this.subscriptions?.emit(targetId, "thread/started", { thread: { ...thread, turns: [] } });
547
+ return response;
548
+ }
549
+ async rollbackLogicalFork(params, clientStock, connectionId) {
550
+ const selection = this.store.getForkSelection(params.threadId);
551
+ const target = this.epochs.resolve(params.threadId);
552
+ if (!selection || selection.status !== "pending" || !target) {
553
+ throw invalidParams(`Thread '${params.threadId}' is not awaiting a logical fork selection.`);
554
+ }
555
+ const turns = this.store.listLogicalTurns(params.threadId);
556
+ const keep = turns.length - params.numTurns;
557
+ if (keep <= 0)
558
+ throw invalidParams("Fork rollback removed every provider turn.");
559
+ const retained = turns.slice(0, keep);
560
+ const boundary = [...retained].reverse().find((turn) => turn.epochId && turn.providerTurnId);
561
+ if (!boundary?.epochId || !boundary.providerTurnId) {
562
+ throw invalidParams("Fork selection has no provider-backed turn boundary.");
563
+ }
564
+ const selectedEpoch = this.store.getEpoch(boundary.epochId);
565
+ if (!selectedEpoch || selectedEpoch.publicThreadId !== selection.sourcePublicThreadId) {
566
+ throw invalidParams("Fork selection points outside the source provider lineage.");
567
+ }
568
+ let forked;
569
+ let stockBuildOwner;
570
+ if (selectedEpoch.provider === "claude") {
571
+ forked = await this.claude.forkThread({
572
+ threadId: selectedEpoch.backendThreadId,
573
+ lastTurnId: boundary.providerTurnId,
574
+ model: selectedEpoch.model,
575
+ });
576
+ }
577
+ else {
578
+ stockBuildOwner = connectionId;
579
+ if (stockBuildOwner) {
580
+ if (this.stockTargetBuilds.has(stockBuildOwner)) {
581
+ throw new Error("Another stock thread materialization is already active on this connection.");
582
+ }
583
+ this.stockTargetBuilds.set(stockBuildOwner, {
584
+ awaitingStart: true,
585
+ threadIds: new Set(),
586
+ messages: [],
587
+ expectedForkedFromId: selectedEpoch.backendThreadId,
588
+ });
589
+ }
590
+ try {
591
+ forked = await clientStock.request("thread/fork", {
592
+ threadId: selectedEpoch.backendThreadId,
593
+ lastTurnId: boundary.providerTurnId,
594
+ model: selectedEpoch.model,
595
+ });
596
+ }
597
+ catch (error) {
598
+ if (stockBuildOwner)
599
+ this.stockTargetBuilds.delete(stockBuildOwner);
600
+ throw error;
601
+ }
602
+ }
603
+ const publicThread = {
604
+ ...forked.thread,
605
+ id: params.threadId,
606
+ sessionId: target.logical.thread.sessionId,
607
+ forkedFromId: selection.sourcePublicThreadId,
608
+ parentThreadId: null,
609
+ createdAt: target.logical.thread.createdAt,
610
+ name: target.logical.thread.name,
611
+ turns: [],
612
+ };
613
+ const committed = this.store.commitForkSelection({
614
+ targetPublicThreadId: params.threadId,
615
+ expectedProvisionalEpochId: selection.provisionalEpochId,
616
+ selectedSourceEpochId: selectedEpoch.id,
617
+ targetEpoch: {
618
+ id: uuidv7(),
619
+ provider: selectedEpoch.provider,
620
+ backendThreadId: forked.thread.id,
621
+ model: selectedEpoch.model,
622
+ settings: selectedEpoch.settings,
623
+ },
624
+ turns: retained.filter((turn) => turn.epochId !== selectedEpoch.id).map((turn) => ({
625
+ publicTurnId: turn.publicTurnId,
626
+ ...(turn.epochId ? { epochId: turn.epochId } : {}),
627
+ ...(turn.providerTurnId ? { providerTurnId: turn.providerTurnId } : {}),
628
+ turn: turn.turn,
629
+ kind: turn.kind,
630
+ })),
631
+ thread: publicThread,
632
+ });
633
+ if (!committed) {
634
+ if (stockBuildOwner)
635
+ this.stockTargetBuilds.delete(stockBuildOwner);
636
+ throw new Error("Logical fork selection lost its atomic commit boundary.");
637
+ }
638
+ this.subscriptions?.suppress(target.epoch.backendThreadId);
639
+ this.subscriptions?.aliasThread(forked.thread.id, params.threadId);
640
+ if (stockBuildOwner)
641
+ this.stockTargetBuilds.delete(stockBuildOwner);
642
+ if (selectedEpoch.provider === "claude")
643
+ await this.claude.announceThread(forked.thread);
644
+ else
645
+ this.subscriptions?.emitPublic(params.threadId, "thread/started", {
646
+ thread: this.epochs.projectThread(params.threadId, forked.thread, true),
647
+ });
648
+ this.subscriptions?.emitPublic(params.threadId, "thread/settings/updated", {
649
+ threadId: params.threadId,
650
+ threadSettings: {
651
+ ...selectedEpoch.settings,
652
+ model: selectedEpoch.model,
653
+ modelProvider: selectedEpoch.provider === "claude" ? "claude" : "openai",
654
+ },
655
+ });
656
+ return { thread: this.epochs.projectThread(params.threadId, forked.thread, true) };
657
+ }
658
+ configureDaemonStock(stock) {
659
+ this.daemonStock = stock;
660
+ for (const job of this.store.recoverableJobs()) {
661
+ const targetId = job.target?.thread && typeof job.target.thread === "object"
662
+ ? job.target.thread.id
663
+ : undefined;
664
+ if (typeof targetId === "string")
665
+ void stock.request("thread/delete", { threadId: targetId }).catch(() => undefined);
666
+ this.store.failJob(job.id, "Legacy cross-provider Fork was retired; change model and send a message instead.");
667
+ }
668
+ for (const job of this.store.recoverableProviderSwitchJobs()) {
669
+ const run = this.jobTail.then(() => this.recoverProviderSwitch(job, stock));
670
+ this.jobTail = run.catch(() => undefined);
671
+ }
672
+ }
673
+ configureSubscriptions(subscriptions) {
674
+ this.subscriptions = subscriptions;
675
+ for (const mapping of this.store.listBackendMappings()) {
676
+ if (mapping.state === "current")
677
+ subscriptions.aliasThread(mapping.backendThreadId, mapping.publicThreadId);
678
+ else
679
+ subscriptions.suppress(mapping.backendThreadId);
680
+ }
681
+ }
682
+ claimFailedFork(threadId) {
683
+ return this.store.claimFailedJob(threadId);
684
+ }
685
+ drain() { return this.jobTail; }
686
+ async switchProviderTurn(params, compactionTurn, clientStock, connectionId, compacted) {
687
+ const stock = this.daemonStock ?? clientStock;
688
+ const seeded = await this.seedProviderSwitch(params.threadId, stock);
689
+ const pending = this.store.getPending(params.threadId);
690
+ if (!pending?.revision || pending.expectedEpochId !== seeded.epoch.id) {
691
+ throw new Error("Provider switch settings changed before the migration turn started.");
692
+ }
693
+ const created = this.store.createProviderSwitchJob({
694
+ id: compactionTurn.id,
695
+ publicThreadId: params.threadId,
696
+ expectedEpochId: seeded.epoch.id,
697
+ pendingRevision: pending.revision,
698
+ targetProvider: pending.targetProvider,
699
+ targetModel: pending.targetModel,
700
+ settings: pending.settings,
701
+ turnParams: params,
702
+ compactionTurn,
703
+ });
704
+ const job = created && this.store.claimProviderSwitchJob(created.id);
705
+ if (!job)
706
+ throw new Error("Another provider switch is already active for this task.");
707
+ return this.runProviderSwitchJob(job, stock, connectionId, compacted);
708
+ }
709
+ async runProviderSwitchJob(job, stock, connectionId, compacted) {
710
+ const params = job.turnParams;
711
+ let target;
712
+ let committed = false;
713
+ try {
714
+ const workerConnectionId = this.daemonStock
715
+ ? `${HANDOFF_DAEMON_CONNECTION_ID}:switch:${job.id}`
716
+ : connectionId;
717
+ const { source, summary, sourceTurns, developerInstructions, targetSettings } = await this.providerSwitchMaterial(job, stock, workerConnectionId);
718
+ if (job.targetProvider === "claude") {
719
+ const started = await this.claude.startThread(this.targetThreadStart(job, source.thread, developerInstructions));
720
+ target = { provider: "claude", threadId: started.thread.id };
721
+ await this.claude.updateThreadSettings({
722
+ ...job.settings,
723
+ threadId: started.thread.id,
724
+ model: job.targetModel,
725
+ });
726
+ const prepared = await this.claude.prepareTurn({ ...params, threadId: started.thread.id });
727
+ if (!this.store.checkpointProviderSwitchTarget(job.id, {
728
+ backendThreadId: started.thread.id,
729
+ summary,
730
+ }))
731
+ throw new Error("Provider switch target lost its durable checkpoint.");
732
+ await prepared.startAndWait();
733
+ if (!this.store.checkpointProviderSwitchTarget(job.id, {
734
+ backendThreadId: started.thread.id,
735
+ providerTurnId: prepared.response.turn.id,
736
+ }))
737
+ throw new Error("Provider switch target delivery lost its durable checkpoint.");
738
+ this.subscriptions?.hideUserMessages(started.thread.id, prepared.response.turn.id);
739
+ this.commitProviderSwitch(job, started.thread, targetSettings, sourceTurns);
740
+ committed = true;
741
+ compacted();
742
+ await prepared.announce();
743
+ return;
744
+ }
745
+ const build = {
746
+ awaitingStart: true,
747
+ threadIds: new Set(),
748
+ messages: [],
749
+ };
750
+ this.stockTargetBuilds.set(workerConnectionId, build);
751
+ const started = await stock.request("thread/start", this.targetThreadStart(job, source.thread, developerInstructions));
752
+ target = { provider: "stock", threadId: started.thread.id };
753
+ if (build.awaitingStart)
754
+ build.awaitingStart = false;
755
+ build.threadIds.add(started.thread.id);
756
+ await stock.request("thread/settings/update", {
757
+ ...job.settings,
758
+ threadId: started.thread.id,
759
+ model: job.targetModel,
760
+ });
761
+ if (!this.store.checkpointProviderSwitchTarget(job.id, {
762
+ backendThreadId: started.thread.id,
763
+ summary,
764
+ }))
765
+ throw new Error("Provider switch target lost its durable checkpoint.");
766
+ const delivered = await stock.request("turn/start", {
767
+ ...params,
768
+ threadId: started.thread.id,
769
+ });
770
+ if (!this.store.checkpointProviderSwitchTarget(job.id, {
771
+ backendThreadId: started.thread.id,
772
+ providerTurnId: delivered.turn.id,
773
+ }))
774
+ throw new Error("Provider switch target delivery lost its durable checkpoint.");
775
+ this.subscriptions?.hideUserMessages(started.thread.id, delivered.turn.id);
776
+ this.commitProviderSwitch(job, started.thread, targetSettings, sourceTurns);
777
+ committed = true;
778
+ compacted();
779
+ this.flushStockTargetBuild(workerConnectionId);
780
+ }
781
+ catch (error) {
782
+ const failure = error instanceof Error ? error : new Error(String(error));
783
+ if (committed)
784
+ return;
785
+ this.store.failProviderSwitch(job.id, failure.message);
786
+ if (target?.provider === "claude") {
787
+ await this.claude.deleteThread(target.threadId).catch(() => undefined);
788
+ }
789
+ else if (target) {
790
+ await stock.request("thread/delete", { threadId: target.threadId }).catch(() => undefined);
791
+ }
792
+ throw failure;
793
+ }
794
+ finally {
795
+ for (const key of [...this.stockTargetBuilds.keys()]) {
796
+ if (key === connectionId || key.endsWith(`:switch:${job.id}`))
797
+ this.stockTargetBuilds.delete(key);
798
+ }
799
+ }
800
+ }
801
+ async recoverProviderSwitch(job, stock) {
802
+ if (job.targetBackendThreadId && job.targetProviderTurnId) {
803
+ const target = job.targetProvider === "claude"
804
+ ? this.claude.readThread(job.targetBackendThreadId, true).thread
805
+ : (await stock.request("thread/read", {
806
+ threadId: job.targetBackendThreadId,
807
+ includeTurns: true,
808
+ })).thread;
809
+ if (target.turns.some((turn) => turn.id === job.targetProviderTurnId)) {
810
+ const material = await this.providerSwitchMaterial(job, stock, `${HANDOFF_DAEMON_CONNECTION_ID}:recovery:${job.id}`);
811
+ this.commitProviderSwitch(job, target, material.targetSettings, material.sourceTurns);
812
+ return;
813
+ }
814
+ }
815
+ if (job.targetBackendThreadId) {
816
+ if (job.targetProvider === "claude") {
817
+ await this.claude.deleteThread(job.targetBackendThreadId).catch(() => undefined);
818
+ }
819
+ else {
820
+ await stock.request("thread/delete", { threadId: job.targetBackendThreadId }).catch(() => undefined);
821
+ }
822
+ }
823
+ const queued = job.status === "queued" ? job : this.store.requeueProviderSwitchJob(job.id);
824
+ const claimed = queued && this.store.claimProviderSwitchJob(job.id);
825
+ if (!claimed)
826
+ return;
827
+ await this.runProviderSwitchJob(claimed, stock, `${HANDOFF_DAEMON_CONNECTION_ID}:recovery:${job.id}`, () => undefined);
828
+ }
110
829
  interceptSettings(params) {
111
- const sourceProvider = this.claude.ownsThread(params.threadId) ? "claude" : "stock";
830
+ const logical = this.epochs.resolve(params.threadId);
831
+ const sourceProvider = logical?.epoch.provider
832
+ ?? (this.claude.ownsThread(params.threadId) ? "claude" : "stock");
112
833
  const previous = this.store.getPending(params.threadId);
113
834
  if (params.model) {
114
835
  const targetProvider = providerForModel(this.claude, params.model);
@@ -127,7 +848,7 @@ export class CrossProviderForks {
127
848
  return {
128
849
  handled: true,
129
850
  ...(sourceProvider === "claude"
130
- ? { notification: stagedSettings(this.claude.currentThreadSettings(params.threadId), pending) }
851
+ ? { notification: stagedSettings(this.claude.currentThreadSettings(logical?.epoch.backendThreadId ?? params.threadId), pending) }
131
852
  : {}),
132
853
  };
133
854
  }
@@ -138,87 +859,232 @@ export class CrossProviderForks {
138
859
  return {
139
860
  handled: true,
140
861
  ...(sourceProvider === "claude"
141
- ? { notification: stagedSettings(this.claude.currentThreadSettings(params.threadId), pending) }
862
+ ? { notification: stagedSettings(this.claude.currentThreadSettings(logical?.epoch.backendThreadId ?? params.threadId), pending) }
142
863
  : {}),
143
864
  };
144
865
  }
145
- blockedTurnTarget(params) {
146
- const pending = this.store.getPending(params.threadId);
147
- const sourceProvider = this.claude.ownsThread(params.threadId) ? "claude" : "stock";
148
- const selectedModel = providerModel(params);
149
- if (selectedModel) {
150
- return providerForModel(this.claude, selectedModel) === sourceProvider ? undefined : selectedModel;
866
+ stageTurnSwitch(params) {
867
+ const selected = providerModel(params);
868
+ if (!selected)
869
+ return this.store.getPending(params.threadId);
870
+ const logical = this.epochs.resolve(params.threadId);
871
+ const sourceProvider = logical?.epoch.provider
872
+ ?? (this.claude.ownsThread(params.threadId) ? "claude" : "stock");
873
+ if (providerForModel(this.claude, selected) === sourceProvider) {
874
+ this.store.clearPending(params.threadId);
875
+ return undefined;
151
876
  }
152
- return pending?.targetModel;
877
+ this.interceptSettings({
878
+ threadId: params.threadId,
879
+ model: selected,
880
+ ...(params.cwd !== undefined ? { cwd: params.cwd } : {}),
881
+ ...(params.approvalPolicy !== undefined ? { approvalPolicy: params.approvalPolicy } : {}),
882
+ ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
883
+ ...(params.sandboxPolicy !== undefined ? { sandboxPolicy: params.sandboxPolicy } : {}),
884
+ ...(params.permissions !== undefined ? { permissions: params.permissions } : {}),
885
+ ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
886
+ ...(params.effort !== undefined ? { effort: params.effort } : {}),
887
+ ...(params.summary !== undefined ? { summary: params.summary } : {}),
888
+ ...(params.collaborationMode !== undefined ? { collaborationMode: params.collaborationMode } : {}),
889
+ ...(params.personality !== undefined ? { personality: params.personality } : {}),
890
+ });
891
+ return this.store.getPending(params.threadId);
892
+ }
893
+ blockedTurnTarget(params) {
894
+ return undefined;
153
895
  }
154
- shouldFork(params) {
155
- const sourceProvider = this.claude.ownsThread(params.threadId) ? "claude" : "stock";
156
- const targetModel = params.model ?? this.store.getPending(params.threadId)?.targetModel;
157
- return Boolean(targetModel && providerForModel(this.claude, targetModel) !== sourceProvider);
896
+ isSystemEphemeralFork(params) {
897
+ const logical = this.epochs.resolve(params.threadId);
898
+ return params.ephemeral === true
899
+ && params.threadSource === "system"
900
+ && (logical?.epoch.provider === "claude" || this.claude.ownsThread(params.threadId))
901
+ && Boolean(params.model && providerForModel(this.claude, params.model) === "stock");
158
902
  }
159
- async fork(params, stock, connectionId = "internal") {
160
- const pending = this.store.getPending(params.threadId);
161
- const targetModel = params.model ?? pending?.targetModel;
162
- if (!targetModel)
163
- throw invalidParams("Cross-provider fork has no staged target model.");
164
- const sourceProvider = this.claude.ownsThread(params.threadId) ? "claude" : "stock";
165
- const targetProvider = providerForModel(this.claude, targetModel);
166
- if (targetProvider === sourceProvider)
167
- throw invalidParams("Cross-provider fork target resolves to the source provider.");
168
- const settings = { ...(pending?.settings ?? { threadId: params.threadId }), threadId: params.threadId, model: targetModel };
169
- let sourceThread;
170
- let inheritedTurns;
171
- let sourceSettings;
172
- let summary;
173
- if (sourceProvider === "claude") {
174
- const source = this.claude.handoffSource(params.threadId, params.lastTurnId);
175
- sourceThread = source.thread;
176
- inheritedTurns = source.turns;
177
- sourceSettings = source.settings;
178
- summary = await this.claude.summarizeHandoff(params.threadId, summaryPrompt(inheritedTurns));
903
+ async forkSystemEphemeral(params, stock, connectionId) {
904
+ if (!this.isSystemEphemeralFork(params) || !params.model) {
905
+ throw invalidParams("System ephemeral fork requires a Claude source and Codex target model.");
179
906
  }
180
- else {
181
- const read = await stock.request("thread/read", { threadId: params.threadId, includeTurns: true });
182
- sourceThread = read.thread;
183
- inheritedTurns = selectedTurns(sourceThread, params.lastTurnId);
184
- const resumed = await stock.request("thread/resume", {
185
- threadId: params.threadId,
186
- excludeTurns: true,
187
- });
188
- const compact = await this.summarizeStock(params, inheritedTurns, resumed.model, stock);
189
- sourceSettings = compact.settings;
190
- summary = compact.summary;
191
- }
192
- const mergedFork = {
193
- ...params,
194
- model: targetModel,
195
- serviceTier: params.serviceTier === undefined
196
- ? settings.serviceTier === undefined ? sourceSettings.serviceTier : settings.serviceTier
197
- : params.serviceTier,
198
- cwd: params.cwd ?? (settings.cwd || sourceSettings.cwd),
199
- approvalPolicy: params.approvalPolicy ?? sourceSettings.approvalPolicy,
200
- approvalsReviewer: params.approvalsReviewer ?? sourceSettings.approvalsReviewer,
201
- ...(params.permissions === undefined && params.sandbox === undefined
202
- ? { sandbox: sandboxMode(sourceSettings.sandboxPolicy) }
203
- : {}),
907
+ const logical = this.epochs.resolve(params.threadId);
908
+ const sourceId = logical?.epoch.provider === "claude" ? logical.epoch.backendThreadId : params.threadId;
909
+ const source = await this.claude.handoffSource(sourceId, params.lastTurnId);
910
+ const start = {
911
+ model: params.model,
912
+ modelProvider: params.modelProvider ?? "openai",
913
+ cwd: params.cwd ?? source.thread.cwd,
914
+ ...(params.runtimeWorkspaceRoots !== undefined ? { runtimeWorkspaceRoots: params.runtimeWorkspaceRoots } : {}),
915
+ ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
916
+ ...(params.approvalPolicy !== undefined ? { approvalPolicy: params.approvalPolicy } : {}),
917
+ ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
918
+ ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}),
919
+ ...(params.permissions !== undefined ? { permissions: params.permissions } : {}),
920
+ ...(params.config !== undefined ? { config: params.config } : {}),
921
+ ...(params.baseInstructions !== undefined ? { baseInstructions: params.baseInstructions } : {}),
922
+ ...(params.developerInstructions !== undefined ? { developerInstructions: params.developerInstructions } : {}),
923
+ ephemeral: true,
924
+ threadSource: "system",
204
925
  };
205
- const developerInstructions = handoffInstructions(params.developerInstructions, summary);
206
- const targetName = forkName(sourceThread, sourceProvider);
207
- let response;
208
- if (targetProvider === "claude") {
209
- response = await this.claude.createHandoffFork(mergedFork, sourceThread, inheritedTurns, settings, developerInstructions, targetName);
926
+ const build = { awaitingStart: true, threadIds: new Set(), messages: [] };
927
+ this.stockTargetBuilds.set(connectionId, build);
928
+ let created;
929
+ try {
930
+ created = await stock.request("thread/start", start);
931
+ if (build.awaitingStart)
932
+ build.awaitingStart = false;
933
+ build.threadIds.add(created.thread.id);
934
+ const items = stockTitleContext(source.turns);
935
+ if (items.length > 0)
936
+ await stock.request("thread/inject_items", { threadId: created.thread.id, items });
937
+ const threads = this.systemEphemeralThreads.get(connectionId) ?? new Map();
938
+ threads.set(created.thread.id, { durableProvider: "claude" });
939
+ this.systemEphemeralThreads.set(connectionId, threads);
940
+ return {
941
+ ...created,
942
+ thread: {
943
+ ...created.thread,
944
+ ephemeral: true,
945
+ threadSource: "system",
946
+ forkedFromId: params.threadId,
947
+ turns: params.excludeTurns ? [] : source.turns,
948
+ },
949
+ };
210
950
  }
211
- else {
212
- response = await this.createStockFork(mergedFork, sourceThread, inheritedTurns, settings, developerInstructions, targetName, stock, connectionId);
951
+ catch (error) {
952
+ if (created)
953
+ await stock.request("thread/delete", { threadId: created.thread.id }).catch(() => undefined);
954
+ throw error;
955
+ }
956
+ finally {
957
+ this.stockTargetBuilds.delete(connectionId);
213
958
  }
214
- this.store.clearPending(params.threadId);
215
- return response;
216
959
  }
217
960
  overlay(threadId) { return this.store.getOverlay(threadId); }
218
961
  ownsInternalStockThread(threadId) { return this.internalStockThreads.has(threadId); }
962
+ captureInternalStockMessage(_connectionId, message) {
963
+ const threadId = messageThreadId(message);
964
+ if (!threadId)
965
+ return false;
966
+ const params = message.params;
967
+ const started = params.thread;
968
+ const build = message.method === "thread/started" && started?.ephemeral === true && started.threadSource !== "system"
969
+ ? [...this.internalStockBuilds.values()].find((candidate) => candidate.awaitingStart && started.forkedFromId === candidate.sourceThreadId)
970
+ : undefined;
971
+ if (build) {
972
+ build.awaitingStart = false;
973
+ build.threadIds.add(threadId);
974
+ this.registerInternalStockThread(threadId);
975
+ }
976
+ if (this.internalStockThreads.has(threadId)) {
977
+ this.recordInternalStockMessage(message);
978
+ return true;
979
+ }
980
+ return this.suppressedInternalStockThreads.has(threadId);
981
+ }
982
+ registerForwardedEphemeralCandidate(connectionId, threadId, params) {
983
+ const directStart = !("threadId" in params);
984
+ if (params.ephemeral !== true)
985
+ return;
986
+ const durableProvider = "threadId" in params
987
+ ? this.claude.ownsThread(params.threadId) ? "claude" : "stock"
988
+ : this.recentDurableProviders.get(connectionId) ?? "stock";
989
+ const candidates = directStart && params.threadSource !== "system" && params.threadSource !== "user"
990
+ ? this.directTitleCandidates
991
+ : params.threadSource === "system" ? this.systemEphemeralThreads : undefined;
992
+ if (!candidates)
993
+ return;
994
+ const threads = candidates.get(connectionId) ?? new Map();
995
+ if (threads.size >= 64 && !threads.has(threadId))
996
+ threads.delete(threads.keys().next().value);
997
+ threads.set(threadId, { durableProvider });
998
+ candidates.set(connectionId, threads);
999
+ }
1000
+ observeDurableThread(connectionId, provider) {
1001
+ this.recentDurableProviders.set(connectionId, provider);
1002
+ }
1003
+ observeDurableTurn(connectionId, params) {
1004
+ if (this.systemEphemeralThreads.get(connectionId)?.has(params.threadId)
1005
+ || this.directTitleCandidates.get(connectionId)?.has(params.threadId))
1006
+ return;
1007
+ const text = params.input.flatMap((input) => input.type === "text" ? [input.text] : []).join("\n").trim();
1008
+ if (!text || titlePrompt(params))
1009
+ return;
1010
+ const durableProvider = this.claude.ownsThread(params.threadId) ? "claude" : "stock";
1011
+ const turn = [...this.titleTurns.values()].reverse()
1012
+ .find((candidate) => candidate.connectionId === connectionId && candidate.userPrompt.trim() === text);
1013
+ if (turn)
1014
+ turn.durableProvider = durableProvider;
1015
+ }
1016
+ prepareTitleTurn(connectionId, params) {
1017
+ const systemOwner = this.systemEphemeralThreads.get(connectionId)?.get(params.threadId);
1018
+ const directOwner = this.directTitleCandidates.get(connectionId)?.get(params.threadId);
1019
+ const owner = systemOwner ?? directOwner;
1020
+ const prompt = owner && titlePrompt(params);
1021
+ if (!owner || !prompt)
1022
+ return params;
1023
+ if (!systemOwner) {
1024
+ const candidates = this.directTitleCandidates.get(connectionId);
1025
+ candidates?.delete(params.threadId);
1026
+ if (candidates?.size === 0)
1027
+ this.directTitleCandidates.delete(connectionId);
1028
+ const threads = this.systemEphemeralThreads.get(connectionId) ?? new Map();
1029
+ threads.set(params.threadId, owner);
1030
+ this.systemEphemeralThreads.set(connectionId, threads);
1031
+ }
1032
+ const renamePrompt = this.renamePrompt;
1033
+ if (!renamePrompt)
1034
+ return params;
1035
+ const turn = {
1036
+ durableProvider: owner.durableProvider,
1037
+ connectionId,
1038
+ userPrompt: prompt.userPrompt,
1039
+ outputSchema: prompt.outputSchema,
1040
+ };
1041
+ this.titleTurns.set(params.threadId, turn);
1042
+ const input = params.input.map((item, index) => index === prompt.index && item.type === "text"
1043
+ ? { ...item, text: rewrittenTitlePrompt(prompt.text, renamePrompt) }
1044
+ : item);
1045
+ return { ...params, input };
1046
+ }
1047
+ rewriteTitleMessages(message) {
1048
+ if (!message.params || typeof message.params !== "object")
1049
+ return undefined;
1050
+ const params = message.params;
1051
+ const threadId = typeof params.threadId === "string" ? params.threadId : undefined;
1052
+ const turn = threadId && this.titleTurns.get(threadId);
1053
+ if (!threadId || !turn)
1054
+ return undefined;
1055
+ if (message.method === "item/agentMessage/delta")
1056
+ return [];
1057
+ const item = params.item && typeof params.item === "object"
1058
+ ? params.item
1059
+ : undefined;
1060
+ if (message.method === "item/completed" && item?.type === "agentMessage" && typeof item.text === "string") {
1061
+ const text = normalizedTitlePayload(item.text, turn);
1062
+ turn.output = text;
1063
+ const completed = { ...message, params: { ...params, item: { ...item, text } } };
1064
+ const delta = typeof params.turnId === "string" && typeof item.id === "string"
1065
+ ? [{ method: "item/agentMessage/delta", params: { threadId, turnId: params.turnId, itemId: item.id, delta: text } }]
1066
+ : [];
1067
+ return [...delta, completed];
1068
+ }
1069
+ if (message.method === "turn/completed") {
1070
+ this.titleTurns.delete(threadId);
1071
+ const completed = params.turn && typeof params.turn === "object"
1072
+ ? params.turn
1073
+ : undefined;
1074
+ if (!turn.output || !Array.isArray(completed?.items))
1075
+ return undefined;
1076
+ const items = completed.items.map((candidate) => candidate && typeof candidate === "object"
1077
+ && candidate.type === "agentMessage"
1078
+ ? { ...candidate, text: turn.output }
1079
+ : candidate);
1080
+ return [{ ...message, params: { ...params, turn: { ...completed, items } } }];
1081
+ }
1082
+ if (message.method === "error")
1083
+ this.titleTurns.delete(threadId);
1084
+ return undefined;
1085
+ }
219
1086
  suppressStockTargetMessage(connectionId, message) {
220
- const build = this.stockTargetBuilds.get(connectionId);
221
- if (!build || !message.params || typeof message.params !== "object")
1087
+ if (!message.params || typeof message.params !== "object")
222
1088
  return false;
223
1089
  const params = message.params;
224
1090
  const nestedThread = params.thread && typeof params.thread === "object"
@@ -229,13 +1095,68 @@ export class CrossProviderForks {
229
1095
  : typeof nestedThread?.id === "string" ? nestedThread.id : undefined;
230
1096
  if (!threadId)
231
1097
  return false;
232
- if (message.method === "thread/started" && build.awaitingStart) {
233
- build.awaitingStart = false;
234
- build.threadIds.add(threadId);
1098
+ if (message.method === "thread/started" && this.systemEphemeralThreads.get(connectionId)?.has(threadId))
1099
+ return true;
1100
+ if (message.method === "thread/started" && this.store.findEpochByBackend("stock", threadId))
1101
+ return true;
1102
+ const direct = this.stockTargetBuilds.get(connectionId);
1103
+ if (direct && message.method === "thread/started" && direct.awaitingStart
1104
+ && (direct.expectedForkedFromId === undefined || nestedThread?.forkedFromId === direct.expectedForkedFromId)) {
1105
+ direct.awaitingStart = false;
1106
+ direct.threadIds.add(threadId);
1107
+ }
1108
+ const pendingFork = message.method === "thread/started"
1109
+ ? [...this.stockTargetBuilds.values()].find((candidate) => candidate.awaitingStart
1110
+ && candidate.expectedForkedFromId !== undefined
1111
+ && nestedThread?.forkedFromId === candidate.expectedForkedFromId)
1112
+ : undefined;
1113
+ if (pendingFork) {
1114
+ pendingFork.awaitingStart = false;
1115
+ pendingFork.threadIds.add(threadId);
1116
+ }
1117
+ const build = direct?.threadIds.has(threadId)
1118
+ ? direct
1119
+ : pendingFork ?? [...this.stockTargetBuilds.values()].find((candidate) => candidate.threadIds.has(threadId));
1120
+ if (!build)
1121
+ return false;
1122
+ if (!build.threadIds.has(threadId))
1123
+ return false;
1124
+ if (message.method !== "thread/started" && message.method !== "thread/settings/updated") {
1125
+ build.messages.push(message);
1126
+ }
1127
+ return true;
1128
+ }
1129
+ flushStockTargetBuild(connectionId) {
1130
+ const build = this.stockTargetBuilds.get(connectionId);
1131
+ if (!build)
1132
+ return;
1133
+ this.stockTargetBuilds.delete(connectionId);
1134
+ for (const message of build.messages) {
1135
+ this.projectStockMessage(message);
235
1136
  }
236
- return build.threadIds.has(threadId);
237
1137
  }
238
- detachConnection(connectionId) { this.stockTargetBuilds.delete(connectionId); }
1138
+ releaseSystemEphemeral(connectionId, threadId) {
1139
+ const threads = this.systemEphemeralThreads.get(connectionId);
1140
+ if (!threads?.delete(threadId))
1141
+ return false;
1142
+ this.titleTurns.delete(threadId);
1143
+ if (threads.size === 0)
1144
+ this.systemEphemeralThreads.delete(connectionId);
1145
+ return true;
1146
+ }
1147
+ ownsSystemEphemeral(connectionId, threadId) {
1148
+ return this.systemEphemeralThreads.get(connectionId)?.has(threadId) === true;
1149
+ }
1150
+ async detachConnection(connectionId, stock) {
1151
+ this.stockTargetBuilds.delete(connectionId);
1152
+ const threads = this.systemEphemeralThreads.get(connectionId) ?? new Map();
1153
+ this.systemEphemeralThreads.delete(connectionId);
1154
+ this.directTitleCandidates.delete(connectionId);
1155
+ this.recentDurableProviders.delete(connectionId);
1156
+ for (const threadId of threads.keys())
1157
+ this.titleTurns.delete(threadId);
1158
+ await Promise.allSettled([...threads.keys()].map((threadId) => stock.request("thread/delete", { threadId })));
1159
+ }
239
1160
  recordInternalStockMessage(message) {
240
1161
  if (!message.params || typeof message.params !== "object")
241
1162
  return;
@@ -248,6 +1169,8 @@ export class CrossProviderForks {
248
1169
  const wireTurn = params.turn && typeof params.turn === "object" ? params.turn : undefined;
249
1170
  const turnId = typeof params.turnId === "string" ? params.turnId : wireTurn?.id;
250
1171
  if (message.method === "error") {
1172
+ if (params.willRetry === true)
1173
+ return;
251
1174
  const error = params.error && typeof params.error === "object" ? params.error : undefined;
252
1175
  const failure = new Error(typeof error?.message === "string" ? error.message : "Stock compact handoff failed.");
253
1176
  for (const state of turns.values())
@@ -299,21 +1222,256 @@ export class CrossProviderForks {
299
1222
  this.store.clearOverlay(threadId);
300
1223
  }
301
1224
  close() { this.store.close(); }
302
- async summarizeStock(params, inheritedTurns, sourceModel, stock) {
303
- const temporary = await stock.request("thread/fork", {
304
- threadId: params.threadId,
305
- ...(params.lastTurnId ? { lastTurnId: params.lastTurnId } : {}),
306
- model: sourceModel,
307
- cwd: params.cwd,
308
- approvalPolicy: "never",
309
- sandbox: "read-only",
310
- developerInstructions: SUMMARY_INSTRUCTIONS,
311
- ephemeral: true,
312
- excludeTurns: true,
1225
+ async seedProviderSwitch(threadId, stock) {
1226
+ let resolved = this.epochs.resolve(threadId);
1227
+ const pending = this.store.getPending(threadId);
1228
+ if (!pending)
1229
+ throw new Error(`Thread '${threadId}' has no staged provider switch.`);
1230
+ if (!resolved) {
1231
+ if (pending.sourceProvider === "claude") {
1232
+ const source = await this.claude.handoffSource(threadId);
1233
+ resolved = this.epochs.seed(source.thread, "claude", source.settings.model, source.settings);
1234
+ }
1235
+ else {
1236
+ const [read, resume] = await Promise.all([
1237
+ stock.request("thread/read", { threadId, includeTurns: true }),
1238
+ stock.request("thread/resume", { threadId, excludeTurns: true }),
1239
+ ]);
1240
+ resolved = this.epochs.seed(read.thread, "stock", resume.model, resume);
1241
+ }
1242
+ }
1243
+ if (resolved.epoch.provider !== pending.sourceProvider) {
1244
+ throw new Error("Provider switch source changed before it could be staged.");
1245
+ }
1246
+ const staged = this.store.stageProviderSwitch({
1247
+ pending: { ...pending, expectedEpochId: resolved.epoch.id },
1248
+ expectedEpochId: resolved.epoch.id,
1249
+ });
1250
+ if (!staged)
1251
+ throw new Error("Provider switch lost its current provider epoch.");
1252
+ return this.epochs.resolve(threadId);
1253
+ }
1254
+ async currentBackendTurns(resolved, stock) {
1255
+ if (resolved.epoch.backendThreadId.startsWith("ccodex-provisional:"))
1256
+ return [];
1257
+ if (resolved.epoch.provider === "claude") {
1258
+ return this.claude.readThread(resolved.epoch.backendThreadId, true).thread.turns;
1259
+ }
1260
+ const read = await stock.request("thread/read", {
1261
+ threadId: resolved.epoch.backendThreadId,
1262
+ includeTurns: true,
1263
+ });
1264
+ return read.thread.turns;
1265
+ }
1266
+ patchLogicalThread(publicThreadId, patch) {
1267
+ const logical = this.store.getLogicalThread(publicThreadId);
1268
+ if (!logical || !this.store.updateLogicalThread(publicThreadId, logical.revision, { ...logical.thread, ...patch, id: publicThreadId, turns: [] }))
1269
+ throw new Error(`Logical thread '${publicThreadId}' changed while metadata was being updated.`);
1270
+ }
1271
+ updateLogicalEpochFromParams(resolved, params) {
1272
+ const settings = { ...params };
1273
+ delete settings.threadId;
1274
+ delete settings.input;
1275
+ delete settings.clientUserMessageId;
1276
+ const model = typeof params.model === "string" ? params.model : undefined;
1277
+ if (!this.store.updateCurrentEpoch(resolved.logical.publicThreadId, resolved.epoch.id, {
1278
+ ...(model ? { model } : {}),
1279
+ settings,
1280
+ }))
1281
+ throw new Error("Current provider epoch changed while settings were being applied.");
1282
+ }
1283
+ async deleteLogicalThread(publicThreadId, clientStock) {
1284
+ const stock = this.daemonStock ?? clientStock;
1285
+ const mappings = this.store.listBackendMappings()
1286
+ .filter((mapping) => mapping.publicThreadId === publicThreadId
1287
+ && !mapping.backendThreadId.startsWith("ccodex-provisional:"));
1288
+ for (const mapping of mappings)
1289
+ this.subscriptions?.suppress(mapping.backendThreadId);
1290
+ await Promise.allSettled(mappings.map((mapping) => mapping.provider === "claude"
1291
+ ? this.claude.deleteThread(mapping.backendThreadId)
1292
+ : stock.request("thread/delete", { threadId: mapping.backendThreadId })));
1293
+ for (const mapping of mappings) {
1294
+ this.subscriptions?.unaliasThread(mapping.backendThreadId);
1295
+ }
1296
+ this.store.clearPending(publicThreadId);
1297
+ this.store.deleteLogicalThread(publicThreadId);
1298
+ this.subscriptions?.threadDeleted(publicThreadId);
1299
+ }
1300
+ async providerSwitchSource(job, stock, _connectionId) {
1301
+ const resolved = this.epochs.resolve(job.publicThreadId);
1302
+ if (!resolved || resolved.epoch.id !== job.expectedEpochId) {
1303
+ throw new Error("Provider switch source epoch is no longer current.");
1304
+ }
1305
+ if (resolved.epoch.provider === "claude") {
1306
+ const source = await this.claude.handoffSource(resolved.epoch.backendThreadId);
1307
+ return {
1308
+ thread: source.thread,
1309
+ backendTurns: source.turns,
1310
+ turns: this.epochs.visibleTurns(job.publicThreadId, source.turns),
1311
+ settings: source.settings,
1312
+ };
1313
+ }
1314
+ const [read, resume] = await Promise.all([
1315
+ stock.request("thread/read", {
1316
+ threadId: resolved.epoch.backendThreadId,
1317
+ includeTurns: true,
1318
+ }),
1319
+ stock.request("thread/resume", {
1320
+ threadId: resolved.epoch.backendThreadId,
1321
+ excludeTurns: true,
1322
+ }),
1323
+ ]);
1324
+ return {
1325
+ thread: read.thread,
1326
+ backendTurns: read.thread.turns,
1327
+ turns: this.epochs.visibleTurns(job.publicThreadId, read.thread.turns),
1328
+ settings: resume,
1329
+ };
1330
+ }
1331
+ async providerSwitchMaterial(job, stock, connectionId) {
1332
+ const source = await this.providerSwitchSource(job, stock, connectionId);
1333
+ const summary = job.summary
1334
+ ?? await this.providerSwitchSummary(job, source.turns, stock, connectionId);
1335
+ const completedAt = Math.floor(Date.now() / 1_000);
1336
+ const compactTurn = {
1337
+ ...job.compactionTurn,
1338
+ status: "completed",
1339
+ completedAt,
1340
+ durationMs: job.compactionTurn.startedAt === null
1341
+ ? null
1342
+ : Math.max(0, (completedAt - job.compactionTurn.startedAt) * 1_000),
1343
+ };
1344
+ return {
1345
+ source,
1346
+ summary,
1347
+ sourceTurns: [
1348
+ ...this.epochs.snapshotTurns(job.publicThreadId, source.backendTurns),
1349
+ { publicTurnId: compactTurn.id, turn: compactTurn, kind: "migrationCompact" },
1350
+ ],
1351
+ developerInstructions: handoffInstructions(source.developerInstructions, summary),
1352
+ targetSettings: {
1353
+ ...source.settings,
1354
+ ...job.settings,
1355
+ model: job.targetModel,
1356
+ modelProvider: job.targetProvider === "claude" ? "claude" : "openai",
1357
+ },
1358
+ };
1359
+ }
1360
+ async providerSwitchSummary(job, turns, stock, connectionId) {
1361
+ const resolved = this.epochs.resolve(job.publicThreadId);
1362
+ if (!resolved)
1363
+ throw new Error(`Unknown logical thread '${job.publicThreadId}'.`);
1364
+ if (resolved.epoch.provider === "claude") {
1365
+ const hidden = await this.claude.forkThread({
1366
+ threadId: resolved.epoch.backendThreadId,
1367
+ model: resolved.epoch.model,
1368
+ ephemeral: true,
1369
+ threadSource: "subAgent",
1370
+ });
1371
+ try {
1372
+ try {
1373
+ return await this.claude.compactForHandoff(hidden.thread.id, `/compact ${SUMMARY_INSTRUCTIONS}`);
1374
+ }
1375
+ catch (error) {
1376
+ if (!(error instanceof Error) || !/not enough messages to compact/i.test(error.message))
1377
+ throw error;
1378
+ return await this.claude.summarizeHandoff(resolved.epoch.backendThreadId, summaryPrompt(turns));
1379
+ }
1380
+ }
1381
+ finally {
1382
+ await this.claude.discardHandoffThread(hidden.thread.id).catch(() => undefined);
1383
+ }
1384
+ }
1385
+ return (await this.summarizeStock({ threadId: resolved.epoch.backendThreadId }, turns, resolved.epoch.model, stock, connectionId)).summary;
1386
+ }
1387
+ targetThreadStart(job, source, developerInstructions) {
1388
+ const turn = job.turnParams;
1389
+ const settings = job.settings;
1390
+ const sandbox = turn.sandboxPolicy ?? settings.sandboxPolicy;
1391
+ return {
1392
+ model: job.targetModel,
1393
+ modelProvider: job.targetProvider === "claude" ? "claude" : "openai",
1394
+ cwd: turn.cwd ?? settings.cwd ?? source.cwd,
1395
+ developerInstructions,
1396
+ ...(turn.serviceTier !== undefined
1397
+ ? { serviceTier: turn.serviceTier }
1398
+ : settings.serviceTier !== undefined ? { serviceTier: settings.serviceTier } : {}),
1399
+ ...(turn.approvalPolicy !== undefined
1400
+ ? { approvalPolicy: turn.approvalPolicy }
1401
+ : settings.approvalPolicy !== undefined ? { approvalPolicy: settings.approvalPolicy } : {}),
1402
+ ...(turn.approvalsReviewer !== undefined
1403
+ ? { approvalsReviewer: turn.approvalsReviewer }
1404
+ : settings.approvalsReviewer !== undefined ? { approvalsReviewer: settings.approvalsReviewer } : {}),
1405
+ ...(turn.permissions !== undefined
1406
+ ? { permissions: turn.permissions }
1407
+ : settings.permissions !== undefined ? { permissions: settings.permissions } : {}),
1408
+ ...(sandbox && turn.permissions === undefined && settings.permissions === undefined
1409
+ ? { sandbox: sandboxMode(sandbox) }
1410
+ : {}),
1411
+ ephemeral: false,
1412
+ threadSource: source.threadSource,
1413
+ };
1414
+ }
1415
+ commitProviderSwitch(job, target, settings, sourceTurns) {
1416
+ const logical = this.store.getLogicalThread(job.publicThreadId);
1417
+ if (!logical)
1418
+ throw new Error(`Unknown logical thread '${job.publicThreadId}'.`);
1419
+ const publicThread = {
1420
+ ...target,
1421
+ id: job.publicThreadId,
1422
+ sessionId: logical.thread.sessionId,
1423
+ forkedFromId: logical.thread.forkedFromId,
1424
+ parentThreadId: logical.thread.parentThreadId,
1425
+ createdAt: logical.thread.createdAt,
1426
+ name: logical.thread.name,
1427
+ turns: [],
1428
+ };
1429
+ const committed = this.store.commitProviderSwitch({
1430
+ jobId: job.id,
1431
+ targetEpoch: {
1432
+ id: uuidv7(),
1433
+ provider: job.targetProvider,
1434
+ backendThreadId: target.id,
1435
+ model: job.targetModel,
1436
+ settings,
1437
+ },
1438
+ sourceTurns,
1439
+ thread: publicThread,
1440
+ });
1441
+ if (!committed)
1442
+ throw new Error("Provider switch lost its atomic commit boundary.");
1443
+ const source = this.store.getEpoch(job.expectedEpochId);
1444
+ this.subscriptions?.suppress(source.backendThreadId);
1445
+ this.subscriptions?.aliasThread(target.id, job.publicThreadId);
1446
+ this.subscriptions?.emitPublic(job.publicThreadId, "thread/settings/updated", {
1447
+ threadId: job.publicThreadId,
1448
+ threadSettings: settings,
313
1449
  });
314
- this.internalStockThreads.add(temporary.thread.id);
315
- this.internalStockTurns.set(temporary.thread.id, new Map());
1450
+ }
1451
+ async summarizeStock(params, inheritedTurns, sourceModel, stock, connectionId) {
1452
+ const build = {
1453
+ awaitingStart: true,
1454
+ sourceThreadId: params.threadId,
1455
+ threadIds: new Set(),
1456
+ messages: [],
1457
+ };
1458
+ this.internalStockBuilds.set(connectionId, build);
1459
+ let temporary;
316
1460
  try {
1461
+ temporary = await stock.request("thread/fork", {
1462
+ threadId: params.threadId,
1463
+ ...(params.lastTurnId ? { lastTurnId: params.lastTurnId } : {}),
1464
+ model: sourceModel,
1465
+ cwd: params.cwd,
1466
+ approvalPolicy: "never",
1467
+ sandbox: "read-only",
1468
+ developerInstructions: SUMMARY_INSTRUCTIONS,
1469
+ ephemeral: true,
1470
+ excludeTurns: true,
1471
+ });
1472
+ build.awaitingStart = false;
1473
+ build.threadIds.add(temporary.thread.id);
1474
+ this.registerInternalStockThread(temporary.thread.id);
317
1475
  const started = await stock.request("turn/start", {
318
1476
  threadId: temporary.thread.id,
319
1477
  input: [{ type: "text", text: summaryPrompt(inheritedTurns), text_elements: [] }],
@@ -342,11 +1500,27 @@ export class CrossProviderForks {
342
1500
  };
343
1501
  }
344
1502
  finally {
345
- await stock.request("thread/delete", { threadId: temporary.thread.id }).catch(() => undefined);
346
- this.internalStockThreads.delete(temporary.thread.id);
347
- this.internalStockTurns.delete(temporary.thread.id);
1503
+ if (temporary)
1504
+ build.threadIds.add(temporary.thread.id);
1505
+ await Promise.allSettled([...build.threadIds].map((threadId) => stock.request("thread/delete", { threadId })));
1506
+ for (const threadId of build.threadIds) {
1507
+ this.internalStockThreads.delete(threadId);
1508
+ this.internalStockTurns.delete(threadId);
1509
+ }
1510
+ if (this.internalStockBuilds.get(connectionId) === build)
1511
+ this.internalStockBuilds.delete(connectionId);
348
1512
  }
349
1513
  }
1514
+ registerInternalStockThread(threadId) {
1515
+ this.internalStockThreads.add(threadId);
1516
+ if (this.suppressedInternalStockThreads.size >= 4096
1517
+ && !this.suppressedInternalStockThreads.has(threadId)) {
1518
+ this.suppressedInternalStockThreads.delete(this.suppressedInternalStockThreads.values().next().value);
1519
+ }
1520
+ this.suppressedInternalStockThreads.add(threadId);
1521
+ if (!this.internalStockTurns.has(threadId))
1522
+ this.internalStockTurns.set(threadId, new Map());
1523
+ }
350
1524
  async waitStockTurn(threadId, turnId) {
351
1525
  const turns = this.internalStockTurns.get(threadId);
352
1526
  if (!turns)
@@ -367,72 +1541,6 @@ export class CrossProviderForks {
367
1541
  state.reject = (error) => { clearTimeout(timeout); reject(error); };
368
1542
  });
369
1543
  }
370
- async createStockFork(params, sourceThread, inheritedTurns, settings, developerInstructions, targetName, stock, connectionId) {
371
- const start = {
372
- model: params.model,
373
- modelProvider: "openai",
374
- cwd: params.cwd ?? sourceThread.cwd,
375
- developerInstructions,
376
- ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
377
- ...(params.approvalPolicy !== undefined ? { approvalPolicy: params.approvalPolicy } : {}),
378
- ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
379
- ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}),
380
- ...(params.permissions !== undefined ? { permissions: params.permissions } : {}),
381
- ...(params.config !== undefined ? { config: params.config } : {}),
382
- ...(params.baseInstructions !== undefined ? { baseInstructions: params.baseInstructions } : {}),
383
- ...(params.ephemeral !== undefined ? { ephemeral: params.ephemeral } : {}),
384
- ...(params.threadSource !== undefined ? { threadSource: params.threadSource } : {}),
385
- };
386
- const build = { awaitingStart: true, threadIds: new Set() };
387
- this.stockTargetBuilds.set(connectionId, build);
388
- let created;
389
- try {
390
- created = await stock.request("thread/start", start);
391
- if (build.awaitingStart)
392
- build.awaitingStart = false;
393
- build.threadIds.add(created.thread.id);
394
- await stock.request("thread/inject_items", {
395
- threadId: created.thread.id,
396
- items: [{
397
- type: "message",
398
- role: "assistant",
399
- content: [{ type: "output_text", text: "Cross-provider handoff initialized." }],
400
- }],
401
- });
402
- await stock.request("thread/name/set", { threadId: created.thread.id, name: targetName });
403
- if (settings.effort !== undefined || settings.summary !== undefined || settings.collaborationMode !== undefined || settings.personality !== undefined) {
404
- await stock.request("thread/settings/update", {
405
- threadId: created.thread.id,
406
- ...(settings.effort !== undefined ? { effort: settings.effort } : {}),
407
- ...(settings.summary !== undefined ? { summary: settings.summary } : {}),
408
- ...(settings.collaborationMode !== undefined ? { collaborationMode: settings.collaborationMode } : {}),
409
- ...(settings.personality !== undefined ? { personality: settings.personality } : {}),
410
- });
411
- }
412
- const thread = {
413
- ...created.thread,
414
- forkedFromId: sourceThread.id,
415
- preview: sourceThread.preview,
416
- name: targetName,
417
- turns: params.excludeTurns ? [] : inheritedTurns,
418
- };
419
- this.store.setOverlay({
420
- threadId: thread.id,
421
- sourceThreadId: sourceThread.id,
422
- sourceThread,
423
- inheritedTurns,
424
- });
425
- return { ...created, thread };
426
- }
427
- catch (error) {
428
- if (created)
429
- await stock.request("thread/delete", { threadId: created.thread.id }).catch(() => undefined);
430
- throw error;
431
- }
432
- finally {
433
- this.stockTargetBuilds.delete(connectionId);
434
- }
435
- }
436
1544
  requireOverlay(threadId) {
437
1545
  const overlay = this.store.getOverlay(threadId);
438
1546
  if (!overlay)