@gkorepanov/ccodex 0.3.0 → 0.3.4

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 (176) hide show
  1. package/README.md +27 -9
  2. package/RELEASING.md +14 -15
  3. package/SSH_VERIFY.md +11 -0
  4. package/archive/README.md +23 -0
  5. package/compatibility.json +1 -2
  6. package/config.example.toml +12 -4
  7. package/dist/claude/asyncQueue.d.ts +3 -0
  8. package/dist/claude/asyncQueue.js +13 -2
  9. package/dist/claude/asyncQueue.js.map +1 -1
  10. package/dist/claude/commandActions.d.ts +6 -0
  11. package/dist/claude/commandActions.js +57 -0
  12. package/dist/claude/commandActions.js.map +1 -0
  13. package/dist/claude/compactCommand.d.ts +2 -0
  14. package/dist/claude/compactCommand.js +7 -0
  15. package/dist/claude/compactCommand.js.map +1 -0
  16. package/dist/claude/goalTools.d.ts +15 -0
  17. package/dist/claude/goalTools.js +173 -0
  18. package/dist/claude/goalTools.js.map +1 -0
  19. package/dist/claude/hookMapper.d.ts +17 -4
  20. package/dist/claude/hookMapper.js +7 -5
  21. package/dist/claude/hookMapper.js.map +1 -1
  22. package/dist/claude/rateLimits.d.ts +72 -0
  23. package/dist/claude/rateLimits.js +330 -0
  24. package/dist/claude/rateLimits.js.map +1 -0
  25. package/dist/claude/responseItemValidation.d.ts +2 -0
  26. package/dist/claude/responseItemValidation.js +40 -0
  27. package/dist/claude/responseItemValidation.js.map +1 -0
  28. package/dist/claude/resultClassifier.d.ts +8 -2
  29. package/dist/claude/resultClassifier.js +1 -1
  30. package/dist/claude/resultClassifier.js.map +1 -1
  31. package/dist/claude/service.d.ts +108 -28
  32. package/dist/claude/service.js +1276 -668
  33. package/dist/claude/service.js.map +1 -1
  34. package/dist/claude/session/backgroundOutput.d.ts +2 -0
  35. package/dist/claude/session/backgroundOutput.js +29 -0
  36. package/dist/claude/session/backgroundOutput.js.map +1 -0
  37. package/dist/claude/session/commands.d.ts +855 -0
  38. package/dist/claude/session/commands.js +2 -0
  39. package/dist/claude/session/commands.js.map +1 -0
  40. package/dist/claude/session/goalState.d.ts +49 -0
  41. package/dist/claude/session/goalState.js +407 -0
  42. package/dist/claude/session/goalState.js.map +1 -0
  43. package/dist/claude/session/mailbox.d.ts +52 -0
  44. package/dist/claude/session/mailbox.js +190 -0
  45. package/dist/claude/session/mailbox.js.map +1 -0
  46. package/dist/claude/session/outputAdapter.d.ts +11 -0
  47. package/dist/claude/session/outputAdapter.js +31 -0
  48. package/dist/claude/session/outputAdapter.js.map +1 -0
  49. package/dist/claude/session/providerFacts.d.ts +47 -0
  50. package/dist/claude/session/providerFacts.js +84 -0
  51. package/dist/claude/session/providerFacts.js.map +1 -0
  52. package/dist/claude/session/providerRuntime.d.ts +1 -0
  53. package/dist/claude/session/providerRuntime.js +2 -0
  54. package/dist/claude/session/providerRuntime.js.map +1 -0
  55. package/dist/claude/session/providerRuntimeFactory.d.ts +49 -0
  56. package/dist/claude/session/providerRuntimeFactory.js +127 -0
  57. package/dist/claude/session/providerRuntimeFactory.js.map +1 -0
  58. package/dist/claude/session/repository.d.ts +57 -0
  59. package/dist/claude/session/repository.js +161 -0
  60. package/dist/claude/session/repository.js.map +1 -0
  61. package/dist/claude/session/runtime.d.ts +46 -0
  62. package/dist/claude/session/runtime.js +140 -0
  63. package/dist/claude/session/runtime.js.map +1 -0
  64. package/dist/claude/session/scopeState.d.ts +61 -0
  65. package/dist/claude/session/scopeState.js +74 -0
  66. package/dist/claude/session/scopeState.js.map +1 -0
  67. package/dist/claude/session/session.d.ts +281 -0
  68. package/dist/claude/session/session.js +6664 -0
  69. package/dist/claude/session/session.js.map +1 -0
  70. package/dist/claude/session/shellRunner.d.ts +13 -0
  71. package/dist/claude/session/shellRunner.js +103 -0
  72. package/dist/claude/session/shellRunner.js.map +1 -0
  73. package/dist/claude/session/usage.d.ts +2 -0
  74. package/dist/claude/session/usage.js +10 -0
  75. package/dist/claude/session/usage.js.map +1 -0
  76. package/dist/claude/sessionRegistry.d.ts +26 -0
  77. package/dist/claude/sessionRegistry.js +118 -0
  78. package/dist/claude/sessionRegistry.js.map +1 -0
  79. package/dist/claude/shellSupervisor.d.ts +1 -0
  80. package/dist/claude/shellSupervisor.js +69 -0
  81. package/dist/claude/shellSupervisor.js.map +1 -0
  82. package/dist/claude/statusCommand.d.ts +21 -0
  83. package/dist/claude/statusCommand.js +97 -0
  84. package/dist/claude/statusCommand.js.map +1 -0
  85. package/dist/claude/toolMapper.d.ts +9 -1
  86. package/dist/claude/toolMapper.js +92 -16
  87. package/dist/claude/toolMapper.js.map +1 -1
  88. package/dist/claude/transcriptBrancher.d.ts +37 -0
  89. package/dist/claude/transcriptBrancher.js +103 -0
  90. package/dist/claude/transcriptBrancher.js.map +1 -0
  91. package/dist/cli/delegate.js +23 -1
  92. package/dist/cli/delegate.js.map +1 -1
  93. package/dist/cli/main.js +28 -12
  94. package/dist/cli/main.js.map +1 -1
  95. package/dist/codex/generated/ResponseItemRuntimeSchema.d.ts +8 -0
  96. package/dist/codex/generated/ResponseItemRuntimeSchema.js +7 -0
  97. package/dist/codex/generated/ResponseItemRuntimeSchema.js.map +1 -0
  98. package/dist/compatibility/probe.js +11 -6
  99. package/dist/compatibility/probe.js.map +1 -1
  100. package/dist/config/config.d.ts +9 -0
  101. package/dist/config/config.js +80 -11
  102. package/dist/config/config.js.map +1 -1
  103. package/dist/daemon/daemon.js +91 -43
  104. package/dist/daemon/daemon.js.map +1 -1
  105. package/dist/daemon/ownership.d.ts +15 -0
  106. package/dist/daemon/ownership.js +185 -0
  107. package/dist/daemon/ownership.js.map +1 -0
  108. package/dist/daemon/supervisor.d.ts +3 -0
  109. package/dist/daemon/supervisor.js +37 -1
  110. package/dist/daemon/supervisor.js.map +1 -1
  111. package/dist/gateway/clientConnection.d.ts +7 -1
  112. package/dist/gateway/clientConnection.js +435 -48
  113. package/dist/gateway/clientConnection.js.map +1 -1
  114. package/dist/gateway/remoteRelay.js +0 -1
  115. package/dist/gateway/remoteRelay.js.map +1 -1
  116. package/dist/gateway/server.d.ts +1 -1
  117. package/dist/gateway/server.js +37 -9
  118. package/dist/gateway/server.js.map +1 -1
  119. package/dist/gateway/subscriptions.d.ts +9 -2
  120. package/dist/gateway/subscriptions.js +87 -9
  121. package/dist/gateway/subscriptions.js.map +1 -1
  122. package/dist/gateway/transientNotice.d.ts +2 -0
  123. package/dist/gateway/transientNotice.js +42 -1
  124. package/dist/gateway/transientNotice.js.map +1 -1
  125. package/dist/handoff/service.d.ts +32 -3
  126. package/dist/handoff/service.js +304 -26
  127. package/dist/handoff/service.js.map +1 -1
  128. package/dist/handoff/titleGeneration.d.ts +28 -0
  129. package/dist/handoff/titleGeneration.js +151 -0
  130. package/dist/handoff/titleGeneration.js.map +1 -0
  131. package/dist/management/doctor.d.ts +1 -1
  132. package/dist/management/doctor.js +32 -23
  133. package/dist/management/doctor.js.map +1 -1
  134. package/dist/management/lifecycle.js +14 -2
  135. package/dist/management/lifecycle.js.map +1 -1
  136. package/dist/management/remoteShim.d.ts +10 -0
  137. package/dist/management/remoteShim.js +65 -0
  138. package/dist/management/remoteShim.js.map +1 -0
  139. package/dist/management/setup.d.ts +4 -0
  140. package/dist/management/setup.js +108 -39
  141. package/dist/management/setup.js.map +1 -1
  142. package/dist/observability/metrics.d.ts +2 -0
  143. package/dist/observability/metrics.js +6 -0
  144. package/dist/observability/metrics.js.map +1 -1
  145. package/dist/protocol/errors.d.ts +1 -0
  146. package/dist/protocol/errors.js +4 -1
  147. package/dist/protocol/errors.js.map +1 -1
  148. package/dist/runtime/dependencies.js +2 -2
  149. package/dist/runtime/dependencies.js.map +1 -1
  150. package/dist/runtime/providerAvailability.d.ts +24 -0
  151. package/dist/runtime/providerAvailability.js +107 -0
  152. package/dist/runtime/providerAvailability.js.map +1 -0
  153. package/dist/state/stateCommand.d.ts +21 -0
  154. package/dist/state/stateCommand.js +198 -0
  155. package/dist/state/stateCommand.js.map +1 -0
  156. package/dist/state/stockStateTracker.d.ts +14 -0
  157. package/dist/state/stockStateTracker.js +120 -0
  158. package/dist/state/stockStateTracker.js.map +1 -0
  159. package/dist/store/HybridStore.d.ts +118 -2
  160. package/dist/store/HybridStore.js +23 -1
  161. package/dist/store/HybridStore.js.map +1 -1
  162. package/dist/store/memoryStore.d.ts +57 -7
  163. package/dist/store/memoryStore.js +337 -5
  164. package/dist/store/memoryStore.js.map +1 -1
  165. package/dist/store/sqliteStore.d.ts +30 -4
  166. package/dist/store/sqliteStore.js +395 -42
  167. package/dist/store/sqliteStore.js.map +1 -1
  168. package/install.sh +2 -2
  169. package/package.json +18 -10
  170. package/dist/claude/interactions.d.ts +0 -23
  171. package/dist/claude/interactions.js +0 -77
  172. package/dist/claude/interactions.js.map +0 -1
  173. package/dist/claude/sessionRuntime.d.ts +0 -102
  174. package/dist/claude/sessionRuntime.js +0 -1361
  175. package/dist/claude/sessionRuntime.js.map +0 -1
  176. /package/{BUILD_BENCHMARKS.md → archive/BUILD_BENCHMARKS.md} +0 -0
@@ -1,16 +1,35 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { statSync } from "node:fs";
3
- import { spawn } from "node:child_process";
4
- import { deleteSession, forkSession, getSessionMessages, renameSession } from "@anthropic-ai/claude-agent-sdk";
3
+ import { deleteSession, renameSession, } from "@anthropic-ai/claude-agent-sdk";
5
4
  import { v7 as uuidv7 } from "uuid";
5
+ import { settingsGeneration } from "../store/HybridStore.js";
6
6
  import { SqliteHybridStore } from "../store/sqliteStore.js";
7
7
  import { LayeredHybridStore } from "../store/memoryStore.js";
8
- import { ClaudeSessionRuntime } from "./sessionRuntime.js";
9
8
  import { createClaudeQuery } from "./queryFactory.js";
10
- import { InteractionBridge } from "./interactions.js";
11
9
  import { invalidParams } from "../protocol/errors.js";
12
10
  import { MetricsRegistry } from "../observability/metrics.js";
11
+ import { SdkTranscriptBrancher } from "./transcriptBrancher.js";
12
+ import { ClaudeRateLimitCoordinator, } from "./rateLimits.js";
13
+ import { isClaudeStatusCommand } from "./statusCommand.js";
14
+ import { systemNoticeText } from "../gateway/transientNotice.js";
13
15
  import { claudeCatalogId, normalizeClaudeServiceTier, resolveClaudeModel, } from "./modelSelection.js";
16
+ import { ClaudeOutputAdapter } from "./session/outputAdapter.js";
17
+ import { branchRevision, ClaudeSessionRepository } from "./session/repository.js";
18
+ import { ClaudeSession } from "./session/session.js";
19
+ import { ShellRunner } from "./session/shellRunner.js";
20
+ import { ClaudeSessionRegistry } from "./sessionRegistry.js";
21
+ import { validateResponseItems } from "./responseItemValidation.js";
22
+ import { providerUnavailableMessage, } from "../runtime/providerAvailability.js";
23
+ import { isCCodexStateCommand, stateModelName, } from "../state/stateCommand.js";
24
+ async function* idleUsagePrompt(signal) {
25
+ if (signal.aborted)
26
+ return;
27
+ await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
28
+ }
29
+ const sdkThreadAdminEffects = {
30
+ rename: async (sessionId, name, cwd) => renameSession(sessionId, name, { dir: cwd }),
31
+ delete: async (sessionId, cwd) => deleteSession(sessionId, { dir: cwd }),
32
+ };
14
33
  function nowSeconds() {
15
34
  return Math.floor(Date.now() / 1_000);
16
35
  }
@@ -26,6 +45,16 @@ function existingThreadCwd(value) {
26
45
  }
27
46
  throw invalidParams(`Claude thread cwd '${cwd}' does not exist or is not a directory.`);
28
47
  }
48
+ function syncedCollaborationMode(value, model, effort) {
49
+ const mode = (value ?? {
50
+ mode: "default",
51
+ settings: { model, reasoning_effort: effort, developer_instructions: null },
52
+ });
53
+ return { ...mode, settings: { ...mode.settings, model, reasoning_effort: effort } };
54
+ }
55
+ function turnReasoningEffort(params) {
56
+ return params.collaborationMode?.settings.reasoning_effort ?? params.effort ?? undefined;
57
+ }
29
58
  function sandboxPolicy(mode, cwd) {
30
59
  if (mode === "read-only")
31
60
  return { type: "readOnly", networkAccess: false };
@@ -48,44 +77,110 @@ function permissionProfileSandboxPolicy(profile, cwd) {
48
77
  return sandboxPolicy("danger-full-access", cwd);
49
78
  throw invalidParams(`Claude threads do not support Codex permission profile '${profile}'.`);
50
79
  }
80
+ function activePermissionProfile(policy) {
81
+ if (policy.type === "readOnly")
82
+ return { id: ":read-only", extends: null };
83
+ if (policy.type === "workspaceWrite")
84
+ return { id: ":workspace", extends: null };
85
+ if (policy.type === "dangerFullAccess")
86
+ return { id: ":danger-full-access", extends: null };
87
+ return null;
88
+ }
51
89
  function approvalsReviewer(value, fallback = "user") {
52
90
  return value ?? fallback;
53
91
  }
54
- function threadResponse(record, includeTurns) {
92
+ function threadSettings(record) {
93
+ const sandboxPolicy = record.sandboxPolicy;
55
94
  return {
56
- thread: { ...record.thread, turns: includeTurns ? record.thread.turns : [] },
95
+ cwd: record.thread.cwd,
96
+ approvalPolicy: record.approvalPolicy,
97
+ approvalsReviewer: record.approvalsReviewer,
98
+ sandboxPolicy,
99
+ activePermissionProfile: activePermissionProfile(sandboxPolicy),
57
100
  model: record.modelPickerId,
58
101
  modelProvider: "claude",
59
102
  serviceTier: record.serviceTier,
60
- cwd: record.thread.cwd,
103
+ effort: record.reasoningEffort,
104
+ summary: record.reasoningSummary,
105
+ collaborationMode: syncedCollaborationMode(record.collaborationMode, record.modelPickerId, record.reasoningEffort),
106
+ multiAgentMode: "explicitRequestOnly",
107
+ personality: record.personality,
108
+ };
109
+ }
110
+ function threadResponse(record, includeTurns) {
111
+ const { sandboxPolicy: sandbox, effort: reasoningEffort, summary: _summary, collaborationMode: _collaborationMode, personality: _personality, ...settings } = threadSettings(record);
112
+ return {
113
+ ...settings,
114
+ thread: { ...record.thread, turns: includeTurns ? record.thread.turns : [] },
61
115
  runtimeWorkspaceRoots: [record.thread.cwd],
62
116
  instructionSources: [],
63
- approvalPolicy: record.approvalPolicy,
64
- approvalsReviewer: record.approvalsReviewer,
65
- sandbox: record.sandboxPolicy,
66
- activePermissionProfile: null,
67
- reasoningEffort: record.reasoningEffort,
68
- multiAgentMode: "explicitRequestOnly",
117
+ sandbox,
118
+ reasoningEffort,
69
119
  };
70
120
  }
71
121
  function clonedTurn(turn) {
72
122
  return structuredClone(turn);
73
123
  }
74
- function failedAfterCrash(turn) {
75
- const completedAt = nowSeconds();
124
+ function forkProjection(turn, targetThreadId) {
125
+ const cloned = clonedTurn(turn);
126
+ const items = [];
127
+ for (const item of cloned.items) {
128
+ if (item.type === "subAgentActivity")
129
+ continue;
130
+ items.push(item.type === "collabAgentToolCall" ? {
131
+ ...item,
132
+ senderThreadId: targetThreadId,
133
+ receiverThreadIds: [],
134
+ agentsStates: {},
135
+ } : item);
136
+ }
137
+ cloned.items = items;
138
+ return cloned;
139
+ }
140
+ function sideReferenceItem(item) {
141
+ const reference = { type: item.type };
142
+ if (item.type === "userMessage")
143
+ reference.content = item.content;
144
+ if (item.type === "agentMessage") {
145
+ reference.text = item.text;
146
+ reference.phase = item.phase;
147
+ }
148
+ if (item.type === "reasoning")
149
+ reference.summary = item.summary;
150
+ if (item.type === "commandExecution") {
151
+ reference.command = item.command;
152
+ reference.cwd = item.cwd;
153
+ }
154
+ if (item.type === "fileChange")
155
+ reference.changes = item.changes;
156
+ if (item.type === "collabAgentToolCall")
157
+ reference.prompt = item.prompt;
158
+ if (item.type === "dynamicToolCall")
159
+ reference.tool = item.tool;
160
+ if (item.type === "mcpToolCall") {
161
+ reference.server = item.server;
162
+ reference.tool = item.tool;
163
+ }
164
+ if (item.type === "webSearch")
165
+ reference.query = item.query;
166
+ if ("status" in item)
167
+ reference.status = item.status;
168
+ return reference;
169
+ }
170
+ function sideReferenceSnapshot(sourceThreadId, turn) {
76
171
  return {
77
- ...turn,
78
- items: turn.items.map((item) => "status" in item && item.status === "inProgress" ? { ...item, status: "failed" } : item),
79
- status: "failed",
80
- completedAt,
81
- durationMs: turn.startedAt === null ? null : Math.max(0, (completedAt - turn.startedAt) * 1_000),
82
- error: {
83
- message: "Gateway restarted while the Claude turn was active.",
84
- codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
85
- additionalDetails: null,
172
+ type: "ccodex_side_parent_snapshot",
173
+ sourceThreadId,
174
+ note: "Reference-only snapshot of the parent turn at fork time. Do not continue or control its live tools.",
175
+ turn: {
176
+ status: turn.status,
177
+ items: turn.items.map(sideReferenceItem),
86
178
  },
87
179
  };
88
180
  }
181
+ function selectedBoundaryIsStable(turn) {
182
+ return turn?.status === "completed";
183
+ }
89
184
  export class ClaudeService {
90
185
  config;
91
186
  hub;
@@ -93,22 +188,92 @@ export class ClaudeService {
93
188
  queryFactory;
94
189
  modelCatalog;
95
190
  metrics;
96
- runtimes = new Map();
191
+ transcripts;
192
+ threadAdminEffects;
193
+ availabilityProbe;
194
+ ephemeralReleases = new Map();
195
+ removalRetries = new Map();
196
+ terminalAdmins = new Map();
97
197
  idleTimer;
98
198
  store;
99
- interactions;
199
+ sessionOutput;
200
+ sessions;
201
+ rateLimits;
100
202
  idleSweep = Promise.resolve();
101
- constructor(config, hub, logger, durableStore = new SqliteHybridStore(join(config.dataDir, "state.sqlite")), queryFactory = createClaudeQuery, modelCatalog, metrics = new MetricsRegistry()) {
203
+ restartRecovery;
204
+ closing = false;
205
+ closePromise;
206
+ standaloneStatusRead;
207
+ constructor(config, hub, logger, durableStore = new SqliteHybridStore(join(config.dataDir, "state.sqlite")), queryFactory = createClaudeQuery, modelCatalog, metrics = new MetricsRegistry(), transcripts = new SdkTranscriptBrancher(), threadAdminEffects = sdkThreadAdminEffects, shellRunner = new ShellRunner(), availabilityProbe = async () => ({
208
+ provider: "claude",
209
+ state: "ready",
210
+ })) {
102
211
  this.config = config;
103
212
  this.hub = hub;
104
213
  this.logger = logger;
105
214
  this.queryFactory = queryFactory;
106
215
  this.modelCatalog = modelCatalog;
107
216
  this.metrics = metrics;
217
+ this.transcripts = transcripts;
218
+ this.threadAdminEffects = threadAdminEffects;
219
+ this.availabilityProbe = availabilityProbe;
108
220
  this.store = new LayeredHybridStore(durableStore);
109
- this.interactions = new InteractionBridge(this.store, hub, metrics);
110
- this.reconcileAfterRestart();
221
+ const sessionRepository = new ClaudeSessionRepository(this.store);
222
+ this.sessionOutput = new ClaudeOutputAdapter(hub);
223
+ this.rateLimits = new ClaudeRateLimitCoordinator(logger);
224
+ this.sessions = new ClaudeSessionRegistry((threadId) => new ClaudeSession(threadId, sessionRepository, this.sessionOutput, undefined, metrics, undefined, (childThreadId) => this.sessions.registerChild(childThreadId, threadId), (childThreadId) => this.sessions.unregisterChild(childThreadId), shellRunner, undefined, {
225
+ claudeBinary: this.config.claudeBinary,
226
+ logger: this.logger,
227
+ queryFactory: this.queryFactory,
228
+ transcripts: this.transcripts,
229
+ rateLimits: this.rateLimits,
230
+ invalidateModelCatalog: () => this.modelCatalog?.invalidate?.(),
231
+ isClosing: () => this.closing,
232
+ }));
233
+ const records = this.store.allThreadRecords();
234
+ const pendingRemovals = this.store.listPendingThreadRemovals();
235
+ const pendingRemovalIds = new Set(pendingRemovals.map((removal) => removal.rootThreadId));
236
+ const byId = new Map(records.map((record) => [record.thread.id, record]));
237
+ const orphanProjectionIds = new Set();
238
+ for (const record of records) {
239
+ if (!record.thread.parentThreadId)
240
+ continue;
241
+ const seen = new Set([record.thread.id]);
242
+ let owner = record;
243
+ while (owner.thread.parentThreadId) {
244
+ const parent = byId.get(owner.thread.parentThreadId);
245
+ if (!parent || seen.has(parent.thread.id)) {
246
+ owner = record;
247
+ break;
248
+ }
249
+ seen.add(parent.thread.id);
250
+ owner = parent;
251
+ }
252
+ if (owner === record) {
253
+ this.logger.warn("claude.orphan-projection.removed", { threadId: record.thread.id });
254
+ orphanProjectionIds.add(record.thread.id);
255
+ continue;
256
+ }
257
+ this.sessions.registerChild(record.thread.id, owner.thread.id);
258
+ }
259
+ const restartRecoveryRootIds = new Set();
260
+ for (const record of records) {
261
+ if (orphanProjectionIds.has(record.thread.id))
262
+ continue;
263
+ if (pendingRemovalIds.has(this.sessions.ownerOf(record.thread.id)))
264
+ continue;
265
+ if (record.thread.status.type === "active"
266
+ || this.store.listTurns(record.thread.id).some((turn) => turn.status === "inProgress")
267
+ || this.store.listPendingRequests(record.thread.id).length > 0
268
+ || this.store.listProviderEvents(record.thread.id, "pending").length > 0) {
269
+ restartRecoveryRootIds.add(this.sessions.ownerOf(record.thread.id));
270
+ }
271
+ }
272
+ this.restartRecovery = this.resumeThreadRemovals(pendingRemovals.map((removal) => removal.rootThreadId))
273
+ .then(() => this.reconcileAfterRestart(orphanProjectionIds, restartRecoveryRootIds));
111
274
  for (const record of this.store.allThreadRecords()) {
275
+ if (orphanProjectionIds.has(record.thread.id))
276
+ continue;
112
277
  for (const request of this.store.listPendingRequests(record.thread.id)) {
113
278
  this.metrics.pendingOpened(request.requestId, request.createdAt);
114
279
  }
@@ -122,147 +287,120 @@ export class ClaudeService {
122
287
  ownsThread(threadId) {
123
288
  return this.store.hasThread(threadId);
124
289
  }
290
+ ready() {
291
+ return this.restartRecovery;
292
+ }
125
293
  ownsModel(modelId) {
126
294
  return resolveClaudeModel(this.config, modelId) !== undefined;
127
295
  }
128
- handoffSource(threadId, lastTurnId) {
129
- const record = this.requireRecord(threadId, true);
296
+ async readRateLimits(foregroundThreadId) {
297
+ if (!this.rateLimits.hasLiveSource && foregroundThreadId && this.ownsThread(foregroundThreadId)) {
298
+ await (await this.sessions.getOrCreate(foregroundThreadId)).ensureRuntime();
299
+ }
300
+ return this.rateLimits.read();
301
+ }
302
+ async readRateLimitStatus(foregroundThreadId) {
303
+ if (!this.rateLimits.hasLiveSource && foregroundThreadId && this.ownsThread(foregroundThreadId)) {
304
+ await (await this.sessions.getOrCreate(foregroundThreadId)).ensureRuntime();
305
+ }
306
+ if (!this.rateLimits.hasLiveSource) {
307
+ this.standaloneStatusRead ??= this.readStandaloneRateLimitStatus()
308
+ .finally(() => { this.standaloneStatusRead = undefined; });
309
+ return this.standaloneStatusRead;
310
+ }
311
+ return this.rateLimits.readStatus();
312
+ }
313
+ cachedRateLimits() {
314
+ return this.rateLimits.cached();
315
+ }
316
+ subscribeRateLimits(connectionId, listener) {
317
+ this.rateLimits.subscribe(connectionId, listener);
318
+ }
319
+ unsubscribeRateLimits(connectionId) {
320
+ this.rateLimits.unsubscribe(connectionId);
321
+ }
322
+ async handoffSource(threadId, lastTurnId) {
323
+ this.requireIndependentThread(threadId, "create a handoff from");
324
+ const snapshot = await this.sessions.submit(threadId, { type: "snapshotBranch" });
130
325
  const through = lastTurnId
131
- ? record.thread.turns.findIndex((turn) => turn.id === lastTurnId)
132
- : record.thread.turns.length - 1;
326
+ ? snapshot.record.thread.turns.findIndex((turn) => turn.id === lastTurnId)
327
+ : snapshot.record.thread.turns.length - 1;
133
328
  if (lastTurnId && through < 0)
134
329
  throw invalidParams(`Unknown Claude turn '${lastTurnId}'.`);
330
+ const turns = snapshot.record.thread.turns.slice(0, through + 1);
135
331
  return {
136
- thread: record.thread,
137
- turns: record.thread.turns.slice(0, through + 1),
138
- settings: this.threadSettings(record),
332
+ thread: { ...snapshot.record.thread, turns },
333
+ turns,
334
+ settings: threadSettings(snapshot.record),
139
335
  };
140
336
  }
141
337
  currentThreadSettings(threadId) {
142
- return this.threadSettings(this.requireRecord(threadId, false));
338
+ return threadSettings(this.requireRecord(threadId, false));
143
339
  }
144
340
  loadedThreadIds() {
145
- return [...this.runtimes.keys()].filter((threadId) => !this.hub.isSuppressed(threadId));
146
- }
147
- reportError(threadId, requestedTurnId, message, codexErrorInfo) {
148
- const record = this.requireRecord(threadId, false);
149
- const turnId = requestedTurnId
150
- ?? this.runtimes.get(threadId)?.activeTurnId
151
- ?? record.lastCompletedTurnId
152
- ?? uuidv7();
153
- const params = {
154
- error: { message, codexErrorInfo, additionalDetails: null },
155
- willRetry: false,
156
- threadId,
157
- turnId,
158
- };
159
- this.emit(threadId, turnId, "error", params);
160
- }
161
- async startThread(params) {
162
- const modelPickerId = params.model;
163
- const claudeModelValue = modelPickerId && resolveClaudeModel(this.config, modelPickerId);
164
- if (!modelPickerId || !claudeModelValue) {
165
- throw invalidParams("Claude thread requires a configured Claude model or alias.");
166
- }
167
- if (params.permissions && params.sandbox) {
168
- throw invalidParams("Claude thread start cannot combine permissions with sandbox.");
169
- }
170
- const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier);
171
- if (serviceTier !== null && serviceTier !== "default" && serviceTier !== "fast") {
172
- throw invalidParams(`Unsupported Claude service tier '${serviceTier}'.`);
341
+ const loaded = new Set(this.sessions.loadedOwnerIds());
342
+ for (const record of this.store.allThreadRecords()) {
343
+ if (record.thread.ephemeral || record.thread.status.type === "active") {
344
+ loaded.add(this.sessions.ownerOf(record.thread.id));
345
+ }
173
346
  }
174
- await this.validateModelSettings(modelPickerId, null, serviceTier);
175
- const createdAt = nowSeconds();
176
- const cwd = existingThreadCwd(params.cwd ?? process.cwd());
177
- const thread = {
178
- id: uuidv7(),
179
- extra: null,
180
- sessionId: uuidv7(),
181
- forkedFromId: null,
182
- parentThreadId: null,
183
- preview: "",
184
- ephemeral: params.ephemeral ?? false,
185
- historyMode: params.historyMode ?? "legacy",
186
- modelProvider: "claude",
187
- createdAt,
188
- updatedAt: createdAt,
189
- recencyAt: createdAt,
190
- status: { type: "notLoaded" },
191
- path: null,
192
- cwd,
193
- cliVersion: "claude-code",
194
- source: "appServer",
195
- threadSource: params.threadSource ?? null,
196
- agentNickname: null,
197
- agentRole: null,
198
- gitInfo: null,
199
- name: null,
200
- turns: [],
201
- };
202
- let record = {
203
- thread,
204
- claudeSessionId: uuidv7(),
205
- modelPickerId,
206
- claudeModelValue,
207
- serviceTier,
208
- approvalPolicy: params.approvalPolicy ?? "on-request",
209
- approvalsReviewer: approvalsReviewer(params.approvalsReviewer),
210
- sandboxPolicy: params.permissions
211
- ? permissionProfileSandboxPolicy(params.permissions, cwd)
212
- : sandboxPolicy(params.sandbox, cwd),
213
- baseInstructions: params.baseInstructions ?? null,
214
- developerInstructions: params.developerInstructions ?? null,
215
- personality: params.personality ?? null,
216
- resolvedModel: null,
217
- lastClaudeMessageUuid: null,
218
- lastCompletedTurnId: null,
219
- claudeCodeVersion: null,
220
- reasoningEffort: null,
221
- reasoningSummary: null,
222
- collaborationMode: null,
223
- outputSchema: null,
224
- tokenUsageTotal: { totalTokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
225
- };
226
- this.store.createThread(record);
347
+ return [...loaded]
348
+ .filter((threadId) => this.store.hasThread(threadId)
349
+ && !this.store.isThreadArchived(threadId)
350
+ && !this.pendingThreadRemoval(threadId)
351
+ && !this.hub.isSuppressed(threadId));
352
+ }
353
+ async reportError(threadId, requestedTurnId, message, codexErrorInfo) {
354
+ if (this.closing || !this.ownsThread(threadId)
355
+ || this.pendingThreadRemoval(threadId) || this.terminalAdmins.has(threadId))
356
+ return;
227
357
  try {
228
- const runtime = await ClaudeSessionRuntime.create(record, this.store, this.hub, this.logger, this.config, false, this.queryFactory, this.interactions, () => this.modelCatalog?.invalidate?.(), this.metrics);
229
- this.runtimes.set(thread.id, runtime);
230
- this.metrics.runtimeLoaded(false);
231
- record = { ...record, thread: { ...thread, status: { type: "idle" } } };
232
- this.store.updateThread(record);
233
- return threadResponse(record, false);
358
+ await this.sessions.submit(threadId, {
359
+ type: "reportError",
360
+ threadId,
361
+ ...(requestedTurnId ? { requestedTurnId } : {}),
362
+ message,
363
+ codexErrorInfo,
364
+ });
234
365
  }
235
366
  catch (error) {
236
- if (/auth|oauth|model.?not.?found/i.test(String(error)))
237
- this.modelCatalog?.invalidate?.();
238
- this.store.deleteThread(thread.id);
239
- throw error;
367
+ if (!this.closing && this.ownsThread(threadId))
368
+ throw error;
240
369
  }
241
370
  }
242
- announceThread(thread) {
243
- this.store.appendEvent(thread.id, null, "thread/started", { thread });
244
- this.hub.emit(thread.id, "thread/started", { thread });
371
+ async startThread(params) {
372
+ let record = await this.newThreadRecord(params);
373
+ record = await this.sessions.submit(record.thread.id, { type: "createThread", record });
374
+ return threadResponse(record, false);
375
+ }
376
+ async announceThread(thread) {
377
+ this.requireIndependentThread(thread.id, "announce");
378
+ await this.sessions.submit(thread.id, { type: "announceThread" });
245
379
  }
246
380
  async resumeThread(params) {
381
+ if (this.closing)
382
+ throw invalidParams("Claude service is closing.");
247
383
  const resume = typeof params === "string" ? { threadId: params } : params;
248
384
  const { threadId } = resume;
385
+ this.assertThreadAvailable(threadId);
249
386
  let record = this.store.getThreadRecord(threadId, true);
250
387
  if (!record)
251
388
  throw invalidParams(`Unknown Claude thread '${threadId}'.`);
252
- const loaded = this.runtimes.get(threadId);
253
- if (loaded) {
254
- await loaded.reinitialize();
255
- }
256
- else {
257
- if (record.thread.ephemeral)
258
- throw invalidParams(`Ephemeral Claude thread '${threadId}' is no longer loaded.`);
259
- const shouldResume = record.lastClaudeMessageUuid !== null;
260
- const runtime = await ClaudeSessionRuntime.create(record, this.store, this.hub, this.logger, this.config, shouldResume, this.queryFactory, this.interactions, () => this.modelCatalog?.invalidate?.(), this.metrics);
261
- this.runtimes.set(threadId, runtime);
262
- this.metrics.runtimeLoaded(shouldResume);
263
- record = { ...record, thread: { ...record.thread, status: { type: "idle" } } };
264
- this.store.updateThread(record);
389
+ if (record.thread.parentThreadId) {
390
+ return {
391
+ ...threadResponse(record, !resume.excludeTurns),
392
+ initialTurnsPage: resume.initialTurnsPage
393
+ ? this.turnsPage({
394
+ threadId,
395
+ ...(resume.initialTurnsPage.limit !== undefined ? { limit: resume.initialTurnsPage.limit } : {}),
396
+ ...(resume.initialTurnsPage.sortDirection !== undefined ? { sortDirection: resume.initialTurnsPage.sortDirection } : {}),
397
+ ...(resume.initialTurnsPage.itemsView !== undefined ? { itemsView: resume.initialTurnsPage.itemsView } : {}),
398
+ })
399
+ : null,
400
+ };
265
401
  }
402
+ await (await this.sessions.getOrCreate(threadId)).materializeRuntime();
403
+ record = await this.sessions.submit(threadId, { type: "readThread", includeTurns: true });
266
404
  return {
267
405
  ...threadResponse(record, !resume.excludeTurns),
268
406
  initialTurnsPage: resume.initialTurnsPage
@@ -275,37 +413,217 @@ export class ClaudeService {
275
413
  : null,
276
414
  };
277
415
  }
416
+ async prepareResume(params) {
417
+ if (this.requireRecord(params.threadId, false).thread.parentThreadId) {
418
+ return {
419
+ response: await this.resumeThread(params),
420
+ notifyGoalSnapshot: () => Promise.resolve(),
421
+ };
422
+ }
423
+ const snapshot = await this.sessions.submit(params.threadId, { type: "goal", command: { kind: "resume" } });
424
+ try {
425
+ const response = await this.resumeThread(params);
426
+ let notified;
427
+ return {
428
+ response,
429
+ notifyGoalSnapshot: (notify) => notified ??= snapshot
430
+ ? this.sessions.submit(params.threadId, {
431
+ type: "goal", command: { kind: "resumeSnapshot", reservationId: snapshot.reservationId },
432
+ }).then((current) => {
433
+ if (current)
434
+ notify("thread/goal/updated", current);
435
+ })
436
+ : Promise.resolve(),
437
+ };
438
+ }
439
+ catch (error) {
440
+ if (snapshot)
441
+ await this.sessions.submit(params.threadId, {
442
+ type: "goal", command: { kind: "resumeSnapshot", reservationId: snapshot.reservationId },
443
+ }).catch(() => undefined);
444
+ throw error;
445
+ }
446
+ }
278
447
  readThread(threadId, includeTurns) {
448
+ this.assertThreadAvailable(threadId);
279
449
  const record = this.store.getThreadRecord(threadId, includeTurns);
280
450
  if (!record)
281
451
  throw invalidParams(`Unknown Claude thread '${threadId}'.`);
282
452
  return { thread: record.thread };
283
453
  }
284
- async prepareTurn(params) {
285
- await this.applyTurnOverrides(params);
286
- const runtime = await this.requireRuntime(params.threadId);
287
- const prepared = await runtime.prepareTurn(params);
288
- return { response: { turn: prepared.turn }, announce: prepared.announce, start: prepared.start };
454
+ async prepareTurn(params, review) {
455
+ this.requireIndependentThread(params.threadId, "start a turn in");
456
+ const availability = await this.availabilityProbe();
457
+ if (availability.state !== "ready")
458
+ throw invalidParams(providerUnavailableMessage(availability));
459
+ await this.sessions.submit(params.threadId, { type: "goal", command: { kind: "reserveTurn" } });
460
+ let preparedTurn = false;
461
+ let staged;
462
+ let session;
463
+ try {
464
+ await this.applyTurnOverrides(params);
465
+ session = await this.sessions.getOrCreate(params.threadId);
466
+ staged = await session.prepareRuntimeTurn(params, Boolean(review));
467
+ const projectedParams = review
468
+ ? { ...params, input: [{ type: "text", text: review.display, text_elements: [] }] }
469
+ : params;
470
+ const prepared = await this.sessions.submit(params.threadId, {
471
+ type: "prepareTurn",
472
+ params: projectedParams,
473
+ ...(review ? { review: review.label } : {}),
474
+ stagedMessageUuid: staged.messageUuid,
475
+ readOnly: staged.readOnly,
476
+ });
477
+ preparedTurn = true;
478
+ const startRuntime = staged.attach(prepared.turn);
479
+ let announcement;
480
+ return {
481
+ response: { turn: prepared.turn },
482
+ announce: () => announcement ??= this.sessions.submit(params.threadId, { type: "announceTurn", turnId: prepared.turn.id }),
483
+ start: () => {
484
+ void (announcement ? announcement.then(startRuntime) : startRuntime()).catch((error) => this.logger.error("claude.turn.start-failed", { threadId: params.threadId, error: String(error) }));
485
+ },
486
+ };
487
+ }
488
+ catch (error) {
489
+ if (staged)
490
+ await staged.discard();
491
+ if (!preparedTurn)
492
+ await this.sessions.submit(params.threadId, { type: "goal", command: { kind: "cancelTurn" } });
493
+ throw error;
494
+ }
495
+ }
496
+ async prepareStatusTurn(params, render) {
497
+ this.requireIndependentThread(params.threadId, "start a status turn in");
498
+ if (!isClaudeStatusCommand(params.input))
499
+ throw invalidParams("Not a CCodex status command.");
500
+ return this.prepareSyntheticCommandTurn(params, "status", render);
501
+ }
502
+ async prepareStateTurn(params, render) {
503
+ this.requireIndependentThread(params.threadId, "start a state turn in");
504
+ if (!isCCodexStateCommand(params.input))
505
+ throw invalidParams("Not a CCodex state command.");
506
+ return this.prepareSyntheticCommandTurn(params, "state", render);
507
+ }
508
+ stateSnapshot(threadId) {
509
+ this.assertThreadAvailable(threadId);
510
+ const record = this.requireRecord(threadId, true);
511
+ return {
512
+ provider: "claude",
513
+ model: stateModelName("claude", record.modelPickerId),
514
+ effort: record.reasoningEffort,
515
+ serviceTier: record.serviceTier,
516
+ approvalPolicy: record.approvalPolicy,
517
+ approvalsReviewer: record.approvalsReviewer,
518
+ sandboxPolicy: record.sandboxPolicy,
519
+ thread: record.thread,
520
+ tokenUsage: record.tokenUsageLast ? {
521
+ total: record.tokenUsageTotal,
522
+ last: record.tokenUsageLast,
523
+ modelContextWindow: record.modelContextWindow,
524
+ } : null,
525
+ providerCostUsd: record.providerCostUsdTotal ?? 0,
526
+ };
527
+ }
528
+ async prepareSyntheticCommandTurn(params, synthetic, render) {
529
+ const prepared = await this.sessions.submit(params.threadId, { type: "prepareTurn", params, synthetic });
530
+ let started = false;
531
+ return {
532
+ response: { turn: prepared.turn },
533
+ announce: () => this.sessions.submit(params.threadId, { type: "announceTurn", turnId: prepared.turn.id }),
534
+ start: () => {
535
+ if (started)
536
+ return;
537
+ started = true;
538
+ void this.completeCommandTurn(params.threadId, prepared.turn.id, synthetic, render);
539
+ },
540
+ };
541
+ }
542
+ async interruptTurn(value) {
543
+ const params = typeof value === "string" ? { threadId: value, turnId: undefined } : value;
544
+ const initial = this.requireRecord(params.threadId, false);
545
+ if (initial.thread.parentThreadId && params.turnId) {
546
+ const selected = this.store.listTurns(params.threadId).find((turn) => turn.id === params.turnId);
547
+ if (selected && selected.status !== "inProgress")
548
+ return;
549
+ }
550
+ const activeOwnerSession = !initial.thread.parentThreadId
551
+ ? this.sessions.resolvedSession(params.threadId)
552
+ : undefined;
553
+ const stopFence = activeOwnerSession?.fenceCurrentRuntimeStop(params.turnId);
554
+ const ownerSession = !initial.thread.parentThreadId
555
+ ? activeOwnerSession ?? await this.sessions.getOrCreate(params.threadId)
556
+ : undefined;
557
+ if (stopFence)
558
+ await stopFence;
559
+ const shell = await this.sessions.submit(params.threadId, {
560
+ type: "prepareShellCancellation",
561
+ ...(params.turnId ? { turnId: params.turnId } : {}),
562
+ });
563
+ if (shell)
564
+ return;
565
+ if (await this.sessions.submit(params.threadId, {
566
+ type: "completeSynthetic", ...(params.turnId ? { turnId: params.turnId } : {}),
567
+ status: "interrupted", codexErrorInfo: null,
568
+ }))
569
+ return;
570
+ const record = this.requireRecord(params.threadId, false);
571
+ if (record.thread.parentThreadId) {
572
+ await (await this.sessions.getOrCreate(params.threadId))
573
+ .interruptChildRuntime(params.threadId, params.turnId);
574
+ return;
575
+ }
576
+ await ownerSession.interruptRuntime(params.turnId);
577
+ }
578
+ async cleanBackgroundTerminals(params) {
579
+ await (await this.sessions.getOrCreate(params.threadId)).cleanBackgroundTerminals(params.threadId);
580
+ return {};
581
+ }
582
+ async listBackgroundTerminals(params) {
583
+ const terminals = await (await this.sessions.getOrCreate(params.threadId)).listBackgroundTerminals(params.threadId);
584
+ const offset = params.cursor === undefined || params.cursor === null ? 0 : Number(params.cursor.replace(/^claude-bg:/u, ""));
585
+ if (!Number.isSafeInteger(offset) || offset < 0)
586
+ throw invalidParams(`Invalid Claude background-terminal cursor '${params.cursor}'.`);
587
+ const limit = Math.min(100, Math.max(1, params.limit ?? 50));
588
+ const data = terminals.slice(offset, offset + limit);
589
+ const next = offset + data.length;
590
+ return { data, nextCursor: next < terminals.length ? `claude-bg:${next}` : null };
289
591
  }
290
- async interruptTurn(threadId) {
291
- await (await this.requireRuntime(threadId)).interrupt();
592
+ async terminateBackgroundTerminal(params) {
593
+ const terminated = await (await this.sessions.getOrCreate(params.threadId))
594
+ .terminateBackgroundTerminal(params.threadId, params.processId);
595
+ return { terminated };
292
596
  }
293
597
  async steerTurn(params) {
294
- return { turnId: await (await this.requireRuntime(params.threadId)).steer(params) };
598
+ return { turnId: await (await this.sessions.getOrCreate(params.threadId)).steerRuntime(params) };
295
599
  }
296
600
  async compactThread(threadId) {
297
- await (await this.requireRuntime(threadId)).compact();
601
+ this.requireIndependentThread(threadId, "compact");
602
+ await (await this.sessions.getOrCreate(threadId)).compactRuntime();
298
603
  return {};
299
604
  }
605
+ async preparePromptedCompact(threadId, input) {
606
+ this.requireIndependentThread(threadId, "compact");
607
+ const prepared = await (await this.sessions.getOrCreate(threadId))
608
+ .preparePromptedCompaction(input);
609
+ return {
610
+ response: { turn: prepared.turn },
611
+ announce: prepared.announce,
612
+ };
613
+ }
300
614
  async updateThreadSettings(params) {
301
- await this.applySettings(params, false);
615
+ this.requireIndependentThread(params.threadId, "update settings for");
616
+ await this.applySettings(params);
302
617
  return {};
303
618
  }
304
619
  async injectItems(params) {
305
- await (await this.requireRuntime(params.threadId)).injectItems(params.items);
620
+ this.requireIndependentThread(params.threadId, "inject items into");
621
+ validateResponseItems(params.items);
622
+ await (await this.sessions.getOrCreate(params.threadId)).injectRuntimeItems(params.items);
306
623
  return {};
307
624
  }
308
625
  async summarizeHandoff(threadId, prompt) {
626
+ this.requireIndependentThread(threadId, "summarize a handoff from");
309
627
  const source = this.requireRecord(threadId, false);
310
628
  const started = await this.startThread({
311
629
  model: source.modelPickerId,
@@ -318,153 +636,84 @@ export class ClaudeService {
318
636
  threadSource: "subAgent",
319
637
  });
320
638
  const temporaryId = started.thread.id;
321
- this.hub.suppress(temporaryId);
322
- try {
323
- const prepared = await this.prepareTurn({
324
- threadId: temporaryId,
325
- input: [{ type: "text", text: prompt, text_elements: [] }],
326
- });
327
- prepared.announce();
328
- prepared.start();
329
- const completed = await new Promise((resolve, reject) => {
330
- const timeout = setTimeout(() => {
331
- clearInterval(poll);
332
- reject(new Error("Claude compact handoff timed out."));
333
- }, 120_000);
334
- const poll = setInterval(() => {
335
- const turn = this.store.listTurns(temporaryId).at(-1);
336
- if (!turn || turn.status === "inProgress")
337
- return;
338
- clearTimeout(timeout);
339
- clearInterval(poll);
340
- if (turn.status !== "completed")
341
- reject(new Error(turn.error?.message ?? "Claude compact handoff failed."));
342
- else
343
- resolve(turn);
344
- }, 25);
345
- });
346
- const text = completed.items.flatMap((item) => item.type === "agentMessage" ? [item.text] : []).join("\n").trim();
347
- if (!text)
348
- throw new Error("Claude compact handoff returned no text.");
349
- return text;
350
- }
351
- finally {
352
- await this.discardThreadSilently(temporaryId);
353
- this.hub.unsuppress(temporaryId);
354
- }
639
+ return this.sessionOutput.withInternalThreadHidden(temporaryId, async () => {
640
+ try {
641
+ const prepared = await this.prepareTurn({
642
+ threadId: temporaryId,
643
+ input: [{ type: "text", text: prompt, text_elements: [] }],
644
+ });
645
+ prepared.announce();
646
+ prepared.start();
647
+ const completed = await new Promise((resolve, reject) => {
648
+ const timeout = setTimeout(() => {
649
+ clearInterval(poll);
650
+ reject(new Error("Claude compact handoff timed out."));
651
+ }, 120_000);
652
+ const poll = setInterval(() => {
653
+ const turn = this.store.listTurns(temporaryId).at(-1);
654
+ if (!turn || turn.status === "inProgress")
655
+ return;
656
+ clearTimeout(timeout);
657
+ clearInterval(poll);
658
+ if (turn.status !== "completed")
659
+ reject(new Error(turn.error?.message ?? "Claude compact handoff failed."));
660
+ else
661
+ resolve(turn);
662
+ }, 25);
663
+ });
664
+ const text = completed.items.flatMap((item) => item.type === "agentMessage" ? [item.text] : []).join("\n").trim();
665
+ if (!text)
666
+ throw new Error("Claude compact handoff returned no text.");
667
+ return text;
668
+ }
669
+ finally {
670
+ await this.discardThreadSilently(temporaryId);
671
+ }
672
+ });
355
673
  }
356
674
  async createHandoffFork(params, sourceThread, inheritedTurns, settings, developerInstructions, targetName) {
357
675
  if (!params.model || !this.ownsModel(params.model))
358
676
  throw invalidParams("Claude handoff fork requires a Claude target model.");
359
- const started = await this.startThread({
360
- model: params.model,
361
- modelProvider: "claude",
362
- cwd: params.cwd ?? sourceThread.cwd,
363
- developerInstructions,
364
- ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
365
- ...(params.approvalPolicy !== undefined ? { approvalPolicy: params.approvalPolicy } : {}),
366
- ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
367
- ...(params.sandbox !== undefined ? { sandbox: params.sandbox } : {}),
368
- ...(params.permissions !== undefined ? { permissions: params.permissions } : {}),
369
- ...(params.config !== undefined ? { config: params.config } : {}),
370
- ...(params.baseInstructions !== undefined ? { baseInstructions: params.baseInstructions } : {}),
677
+ const initial = await this.newThreadRecord({
678
+ ...params, model: params.model, cwd: params.cwd ?? sourceThread.cwd, developerInstructions,
371
679
  ...(settings.personality !== undefined ? { personality: settings.personality } : {}),
372
- ...(params.ephemeral !== undefined ? { ephemeral: params.ephemeral } : {}),
373
- ...(params.threadSource !== undefined ? { threadSource: params.threadSource } : {}),
374
- });
375
- let current = this.requireRecord(started.thread.id, false);
376
- const configured = {
377
- ...current,
378
- reasoningEffort: settings.effort === undefined ? current.reasoningEffort : settings.effort,
379
- reasoningSummary: settings.summary === undefined ? current.reasoningSummary : settings.summary,
380
- collaborationMode: settings.collaborationMode === undefined ? current.collaborationMode : settings.collaborationMode,
381
- };
382
- await this.validateModelSettings(configured.modelPickerId, configured.reasoningEffort, configured.serviceTier);
383
- if (["reasoningEffort", "reasoningSummary", "collaborationMode"].some((key) => JSON.stringify(configured[key]) !== JSON.stringify(current[key]))) {
384
- await this.unloadRuntime(started.thread.id, "Fresh handoff target settings applied.");
385
- this.store.updateThread(configured);
386
- const runtime = await ClaudeSessionRuntime.create(configured, this.store, this.hub, this.logger, this.config, false, this.queryFactory, this.interactions, () => this.modelCatalog?.invalidate?.(), this.metrics);
387
- this.runtimes.set(started.thread.id, runtime);
388
- this.metrics.runtimeLoaded(false);
389
- current = { ...configured, thread: { ...configured.thread, status: { type: "idle" } } };
390
- this.store.updateThread(current);
391
- }
680
+ }, settings);
392
681
  const now = nowSeconds();
393
- const updated = {
394
- ...current,
395
- lastCompletedTurnId: inheritedTurns.at(-1)?.id ?? null,
682
+ const configured = {
683
+ ...initial,
396
684
  thread: {
397
- ...current.thread,
398
- forkedFromId: sourceThread.id,
399
- preview: sourceThread.preview,
400
- name: targetName,
401
- updatedAt: now,
402
- recencyAt: now,
685
+ ...initial.thread, forkedFromId: sourceThread.id, preview: sourceThread.preview,
686
+ name: targetName, updatedAt: now, recencyAt: now,
403
687
  },
404
688
  };
405
- this.store.updateThread(updated);
406
- for (const turn of inheritedTurns)
407
- this.store.createTurn(updated.thread.id, clonedTurn(turn));
408
- this.runtimes.get(updated.thread.id)?.synchronizeRecord(updated);
409
- return threadResponse({ ...updated, thread: { ...updated.thread, turns: inheritedTurns } }, !params.excludeTurns);
689
+ const turns = inheritedTurns.map(clonedTurn);
690
+ const committed = await this.sessions.submit(configured.thread.id, {
691
+ type: "commitForkTarget", record: configured, turns, sourceBoundaries: [], uuidMap: [],
692
+ });
693
+ return threadResponse({ ...committed, thread: { ...committed.thread, turns } }, !params.excludeTurns);
410
694
  }
411
695
  async shellCommand(params) {
412
- const runtime = this.runtimes.get(params.threadId);
413
- if (runtime?.isActive)
414
- throw invalidParams("Cannot run a thread shell command during an active Claude turn.");
415
- const record = this.requireRecord(params.threadId, false);
416
- const now = nowSeconds();
417
- let item = {
418
- type: "commandExecution", id: uuidv7(), command: params.command, cwd: record.thread.cwd,
419
- processId: null, source: "userShell", status: "inProgress", commandActions: [],
420
- aggregatedOutput: "", exitCode: null, durationMs: null,
421
- };
422
- let turn = {
423
- id: uuidv7(), items: [item], itemsView: "full", status: "inProgress", error: null,
424
- startedAt: now, completedAt: null, durationMs: null,
425
- };
426
- this.store.createTurn(params.threadId, turn);
427
- this.store.updateThread({ ...record, thread: { ...record.thread, status: { type: "active", activeFlags: [] }, updatedAt: now } });
428
- this.emit(params.threadId, turn.id, "turn/started", { threadId: params.threadId, turn });
429
- this.emit(params.threadId, turn.id, "item/started", { item, threadId: params.threadId, turnId: turn.id, startedAtMs: Date.now() });
430
- const startedAt = Date.now();
431
- const child = spawn(process.env.SHELL ?? "/bin/sh", ["-lc", params.command], { cwd: record.thread.cwd, env: process.env });
432
- const append = (bytes) => {
433
- const delta = bytes.toString();
434
- if (item.type !== "commandExecution")
435
- return;
436
- item = { ...item, aggregatedOutput: `${item.aggregatedOutput ?? ""}${delta}` };
437
- turn = { ...turn, items: [item] };
438
- this.store.updateTurn(params.threadId, turn);
439
- this.emit(params.threadId, turn.id, "item/commandExecution/outputDelta", { threadId: params.threadId, turnId: turn.id, itemId: item.id, delta });
440
- };
441
- child.stdout.on("data", append);
442
- child.stderr.on("data", append);
443
- const exitCode = await new Promise((done, reject) => {
444
- child.once("error", reject);
445
- child.once("close", (code) => done(code ?? 1));
446
- });
447
- const completedAt = nowSeconds();
448
- item = { ...item, status: exitCode === 0 ? "completed" : "failed", exitCode, durationMs: Date.now() - startedAt };
449
- turn = {
450
- ...turn, items: [item], status: exitCode === 0 ? "completed" : "failed",
451
- completedAt, durationMs: Date.now() - startedAt,
452
- error: exitCode === 0 ? null : { message: `Shell command exited with code ${exitCode}.`, codexErrorInfo: null, additionalDetails: null },
453
- };
454
- this.store.updateTurn(params.threadId, turn);
455
- const current = this.requireRecord(params.threadId, false);
456
- const updatedRecord = {
457
- ...current,
458
- lastCompletedTurnId: turn.id,
459
- thread: { ...current.thread, status: { type: "idle" }, updatedAt: completedAt, recencyAt: completedAt },
460
- };
461
- this.store.updateThread(updatedRecord);
462
- runtime?.synchronizeRecord(updatedRecord);
463
- this.emit(params.threadId, turn.id, "item/completed", { item, threadId: params.threadId, turnId: turn.id, completedAtMs: Date.now() });
464
- this.emit(params.threadId, turn.id, "turn/completed", { threadId: params.threadId, turn });
696
+ this.requireIndependentThread(params.threadId, "run a shell command in");
697
+ await this.sessions.submit(params.threadId, { type: "runShell", command: params.command });
465
698
  return {};
466
699
  }
467
700
  async prepareReview(params) {
701
+ const value = (text, field) => {
702
+ const trimmed = text.trim();
703
+ if (!trimmed)
704
+ throw invalidParams(`Review ${field} must not be empty.`);
705
+ return trimmed;
706
+ };
707
+ const review = params.target.type === "uncommittedChanges"
708
+ ? "current changes"
709
+ : params.target.type === "baseBranch"
710
+ ? `changes against '${value(params.target.branch, "branch")}'`
711
+ : params.target.type === "commit"
712
+ ? `commit ${value(params.target.sha, "sha").slice(0, 7)}${params.target.title?.trim() ? `: ${params.target.title.trim()}` : ""}`
713
+ : value(params.target.instructions, "instructions");
714
+ const prompt = params.target.type === "custom"
715
+ ? review
716
+ : `Review ${review}. Inspect the repository and report concrete bugs, regressions, and risks with file and line references. Do not modify files.`;
468
717
  let reviewThreadId = params.threadId;
469
718
  let forkedThread;
470
719
  if (params.delivery === "detached") {
@@ -472,37 +721,41 @@ export class ClaudeService {
472
721
  reviewThreadId = fork.thread.id;
473
722
  forkedThread = fork.thread;
474
723
  }
475
- const review = params.target.type === "uncommittedChanges"
476
- ? "uncommitted changes"
477
- : params.target.type === "baseBranch"
478
- ? `changes against base branch ${params.target.branch}`
479
- : params.target.type === "commit"
480
- ? `commit ${params.target.sha}${params.target.title ? ` (${params.target.title})` : ""}`
481
- : "custom review";
482
- const prompt = params.target.type === "custom"
483
- ? params.target.instructions
484
- : `Review ${review}. Inspect the repository and report concrete bugs, regressions, and risks with file and line references. Do not modify files.`;
485
- await this.applySettings({
486
- threadId: reviewThreadId,
487
- sandboxPolicy: { type: "readOnly", networkAccess: false },
488
- approvalsReviewer: "user",
489
- }, true);
490
- const runtime = await this.requireRuntime(reviewThreadId);
491
- const prepared = await runtime.prepareTurn({
492
- threadId: reviewThreadId,
493
- input: [{ type: "text", text: prompt, text_elements: [] }],
494
- }, review);
495
- return {
496
- response: { turn: prepared.turn, reviewThreadId },
497
- announce: prepared.announce,
498
- start: prepared.start,
499
- ...(forkedThread ? { forkedThread } : {}),
500
- };
724
+ try {
725
+ const prepared = await this.prepareTurn({
726
+ threadId: reviewThreadId,
727
+ input: [{ type: "text", text: prompt, text_elements: [] }],
728
+ }, { label: review, display: review });
729
+ const userItem = prepared.response.turn.items.find((item) => item.type === "userMessage");
730
+ return {
731
+ response: {
732
+ turn: {
733
+ ...prepared.response.turn,
734
+ items: userItem ? [userItem] : [],
735
+ itemsView: "notLoaded",
736
+ startedAt: null,
737
+ completedAt: null,
738
+ durationMs: null,
739
+ },
740
+ reviewThreadId,
741
+ },
742
+ announce: prepared.announce,
743
+ start: prepared.start,
744
+ ...(forkedThread ? { forkedThread } : {}),
745
+ };
746
+ }
747
+ catch (error) {
748
+ if (forkedThread)
749
+ await this.discardThreadSilently(reviewThreadId);
750
+ throw error;
751
+ }
501
752
  }
502
753
  listTurns(threadId) {
754
+ this.assertThreadAvailable(threadId);
503
755
  return this.store.listTurns(threadId);
504
756
  }
505
757
  turnsPage(params) {
758
+ this.assertThreadAvailable(params.threadId);
506
759
  const turns = this.listTurns(params.threadId);
507
760
  const ordered = params.sortDirection === "asc" ? turns : [...turns].reverse();
508
761
  if (params.cursor && !params.cursor.startsWith("hyb-turn:"))
@@ -524,61 +777,107 @@ export class ClaudeService {
524
777
  };
525
778
  }
526
779
  listThreads(params) {
527
- return this.store.listThreads(params).filter((thread) => !this.hub.isSuppressed(thread.id));
780
+ return this.store.listThreads(params)
781
+ .filter((thread) => !this.pendingThreadRemoval(thread.id) && !this.hub.isSuppressed(thread.id));
528
782
  }
529
783
  async setThreadName(params) {
530
784
  const record = this.requireRecord(params.threadId, false);
531
- if (!record.thread.ephemeral && record.lastCompletedTurnId) {
532
- await renameSession(record.claudeSessionId, params.name, { dir: record.thread.cwd });
785
+ if (record.thread.parentThreadId) {
786
+ await this.sessions.submit(params.threadId, {
787
+ type: "threadAdmin",
788
+ command: { kind: "renameProjection", threadId: params.threadId, name: params.name },
789
+ });
790
+ return {};
533
791
  }
534
- const updated = { ...record, thread: { ...record.thread, name: params.name, updatedAt: nowSeconds() } };
535
- this.store.updateThread(updated);
536
- this.runtimes.get(params.threadId)?.synchronizeRecord(updated);
537
- this.hub.emit(params.threadId, "thread/name/updated", { threadId: params.threadId, threadName: params.name });
792
+ await this.threadAdminEffect(params.threadId, "rename", async (prepared) => {
793
+ if (prepared.providerRename) {
794
+ await this.threadAdminEffects.rename(prepared.record.claudeSessionId, params.name, prepared.record.thread.cwd);
795
+ }
796
+ }, params.name);
538
797
  return {};
539
798
  }
540
799
  updateThreadMetadata(params) {
541
- const record = this.requireRecord(params.threadId, false);
542
- const previous = record.thread.gitInfo ?? { sha: null, branch: null, originUrl: null };
543
- const patch = params.gitInfo;
544
- const gitInfo = patch === null ? null : patch === undefined ? record.thread.gitInfo : {
545
- sha: patch.sha === undefined ? previous.sha : patch.sha,
546
- branch: patch.branch === undefined ? previous.branch : patch.branch,
547
- originUrl: patch.originUrl === undefined ? previous.originUrl : patch.originUrl,
548
- };
549
- const updated = { ...record, thread: { ...record.thread, gitInfo, updatedAt: nowSeconds() } };
550
- this.store.updateThread(updated);
551
- this.runtimes.get(params.threadId)?.synchronizeRecord(updated);
552
- return { thread: updated.thread };
800
+ this.requireIndependentThread(params.threadId, "update metadata for");
801
+ return this.sessions.submit(params.threadId, {
802
+ type: "threadAdmin",
803
+ command: { kind: "metadata", gitInfo: params.gitInfo },
804
+ });
553
805
  }
554
806
  async archiveThread(threadId) {
555
- const record = this.requireRecord(threadId, false);
556
- if (!record.thread.ephemeral)
557
- await this.unloadRuntime(threadId, "Claude thread archived during an active turn.");
558
- this.store.setThreadArchived(threadId, true);
559
- this.hub.emit(threadId, "thread/archived", { threadId });
560
- return {};
807
+ this.requireIndependentThread(threadId, "archive");
808
+ this.terminalAdmins.set(threadId, (this.terminalAdmins.get(threadId) ?? 0) + 1);
809
+ try {
810
+ await this.threadAdminEffect(threadId, "archive", async (prepared) => {
811
+ await this.failStatusTurnForUnload(threadId, "Claude thread archived during an active turn.");
812
+ await this.sessions.submit(threadId, { type: "goal", command: { kind: "detach", checkpoint: "unload" } });
813
+ if (!prepared.record.thread.ephemeral) {
814
+ await (await this.sessions.getOrCreate(threadId))
815
+ .retireRuntime("Claude thread archived during an active turn.");
816
+ }
817
+ });
818
+ await this.sessions.retire(threadId);
819
+ return {};
820
+ }
821
+ finally {
822
+ const remaining = this.terminalAdmins.get(threadId) - 1;
823
+ if (remaining)
824
+ this.terminalAdmins.set(threadId, remaining);
825
+ else
826
+ this.terminalAdmins.delete(threadId);
827
+ }
561
828
  }
562
829
  unarchiveThread(threadId) {
563
- this.requireRecord(threadId, false);
564
- this.store.setThreadArchived(threadId, false);
565
- const thread = this.requireRecord(threadId, false).thread;
566
- this.hub.emit(threadId, "thread/unarchived", { threadId });
567
- return { thread };
830
+ this.requireIndependentThread(threadId, "unarchive");
831
+ return this.sessions.submit(threadId, {
832
+ type: "threadAdmin",
833
+ command: { kind: "unarchive" },
834
+ });
568
835
  }
569
836
  async deleteThread(threadId) {
570
- const record = this.requireRecord(threadId, false);
571
- this.interactions.cancelThread(threadId);
572
- await this.unloadRuntime(threadId, "Claude thread deleted during an active turn.");
573
- await deleteSession(record.claudeSessionId, { dir: record.thread.cwd }).catch((error) => {
574
- if (!String(error).toLocaleLowerCase().includes("not found"))
575
- throw error;
576
- });
577
- this.store.deleteThread(threadId);
578
- this.hub.emit(threadId, "thread/deleted", { threadId });
837
+ const pending = this.pendingThreadRemoval(threadId);
838
+ if (pending) {
839
+ if (pending.rootThreadId !== threadId || pending.kind !== "delete")
840
+ this.throwPendingRemoval(threadId, pending);
841
+ await this.resumeThreadRemoval(threadId);
842
+ return {};
843
+ }
844
+ this.requireIndependentThread(threadId, "delete");
845
+ await this.removeThread(threadId, "delete", "Claude thread deleted during an active turn.");
579
846
  return {};
580
847
  }
848
+ releaseEphemeralThread(threadId) {
849
+ const existing = this.ephemeralReleases.get(threadId);
850
+ if (existing)
851
+ return existing;
852
+ if (this.closing)
853
+ return Promise.resolve();
854
+ const release = this.releaseEphemeralThreadOnce(threadId);
855
+ this.ephemeralReleases.set(threadId, release);
856
+ const remove = () => {
857
+ if (this.ephemeralReleases.get(threadId) === release)
858
+ this.ephemeralReleases.delete(threadId);
859
+ };
860
+ void release.then(remove, remove);
861
+ return release;
862
+ }
863
+ async releaseEphemeralThreadOnce(threadId) {
864
+ const pending = this.pendingThreadRemoval(threadId);
865
+ if (pending) {
866
+ if (pending.rootThreadId !== threadId || pending.kind !== "release")
867
+ this.throwPendingRemoval(threadId, pending);
868
+ await this.resumeThreadRemoval(threadId);
869
+ return;
870
+ }
871
+ const record = this.store.getThreadRecord(threadId, false);
872
+ if (record?.thread.parentThreadId) {
873
+ throw invalidParams(`Cannot release Claude subagent thread '${threadId}'; it is a read-only projection.`);
874
+ }
875
+ if (!record?.thread.ephemeral)
876
+ return;
877
+ await this.removeThread(threadId, "release", "Ephemeral side thread released.");
878
+ }
581
879
  listItems(params) {
880
+ this.assertThreadAvailable(params.threadId);
582
881
  const items = this.store.listTurns(params.threadId)
583
882
  .flatMap((turn) => params.turnId && turn.id !== params.turnId ? [] : turn.items);
584
883
  const ordered = params.sortDirection === "desc" ? [...items].reverse() : items;
@@ -598,346 +897,646 @@ export class ClaudeService {
598
897
  async forkThread(params) {
599
898
  if (params.path)
600
899
  throw invalidParams("Claude thread forks must use threadId, not a Codex rollout path.");
601
- const source = this.requireRecord(params.threadId, true);
602
- if (source.thread.ephemeral)
900
+ this.requireIndependentThread(params.threadId, "fork");
901
+ const source = await this.sessions.submit(params.threadId, { type: "snapshotBranch" });
902
+ const sourceRecord = source.record;
903
+ if (sourceRecord.thread.ephemeral)
603
904
  throw invalidParams("Forking an ephemeral Claude source thread is not supported.");
604
- if (this.runtimes.get(params.threadId)?.isActive)
605
- throw invalidParams("Cannot fork a Claude thread with an active turn.");
606
- const modelPickerId = params.model ?? source.modelPickerId;
905
+ const modelPickerId = params.model ?? sourceRecord.modelPickerId;
607
906
  const claudeModelValue = resolveClaudeModel(this.config, modelPickerId);
608
907
  if (!claudeModelValue)
609
908
  throw invalidParams("Cannot fork a Claude thread to a Codex model.");
909
+ const sourceSession = await this.sessions.getOrCreate(params.threadId);
910
+ const activeSideFork = Boolean(params.ephemeral === true && params.excludeTurns === true
911
+ && params.threadSource === "user" && !params.lastTurnId);
610
912
  if (params.permissions && params.sandbox)
611
913
  throw invalidParams("Claude thread fork cannot combine permissions with sandbox.");
612
- const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier === undefined ? source.serviceTier : params.serviceTier);
613
- await this.validateModelSettings(modelPickerId, source.reasoningEffort, serviceTier);
614
- const through = params.lastTurnId
615
- ? source.thread.turns.findIndex((turn) => turn.id === params.lastTurnId)
616
- : source.thread.turns.length - 1;
617
- if (params.lastTurnId && through < 0)
914
+ const activeIndex = sourceRecord.thread.turns.findIndex((turn) => turn.status === "inProgress");
915
+ const selectedIndex = params.lastTurnId
916
+ ? sourceRecord.thread.turns.findIndex((turn) => turn.id === params.lastTurnId)
917
+ : -1;
918
+ if (params.lastTurnId && selectedIndex < 0)
618
919
  throw invalidParams(`Unknown Claude turn '${params.lastTurnId}'.`);
619
- if (through >= 0 && source.thread.turns[through]?.status === "inProgress")
620
- throw invalidParams("Cannot fork through an active turn.");
621
- const sourceTurns = source.thread.turns.slice(0, through + 1);
622
- const boundary = this.lastProviderBoundary(source.thread.id, sourceTurns);
623
- const branch = await this.forkTranscript(source, boundary);
920
+ const selectedTurn = selectedIndex >= 0 ? sourceRecord.thread.turns[selectedIndex] : undefined;
921
+ const selectedBoundary = params.lastTurnId
922
+ ? source.boundaries.find((entry) => entry.turnId === params.lastTurnId)
923
+ : undefined;
924
+ if (params.lastTurnId && !selectedBoundaryIsStable(selectedTurn)) {
925
+ throw invalidParams(`Claude turn '${params.lastTurnId}' is not completed and cannot be used as a fork boundary.`);
926
+ }
927
+ if (!params.lastTurnId && !activeSideFork
928
+ && (activeIndex >= 0 || (await sourceSession.runtimeInspection())?.quiescent === false)) {
929
+ throw invalidParams("Cannot fork a Claude thread with an active turn without selecting a completed turn.");
930
+ }
931
+ const through = activeSideFork
932
+ ? activeIndex >= 0 ? activeIndex - 1 : sourceRecord.thread.turns.length - 1
933
+ : params.lastTurnId
934
+ ? selectedIndex
935
+ : sourceRecord.thread.turns.length - 1;
936
+ const sourceTurns = sourceRecord.thread.turns.slice(0, through + 1);
937
+ const selectedIds = new Set(sourceTurns.map((turn) => turn.id));
938
+ const sourceBoundaries = source.boundaries.filter((entry) => selectedIds.has(entry.turnId));
939
+ const revision = branchRevision(sourceRecord, sourceBoundaries, sourceTurns);
940
+ const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier === undefined ? sourceRecord.serviceTier : params.serviceTier);
941
+ await this.validateModelSettings(modelPickerId, sourceRecord.reasoningEffort, serviceTier);
942
+ const boundary = selectedBoundary?.messageUuid ?? sourceBoundaries.at(-1)?.messageUuid;
943
+ const branch = boundary
944
+ ? await this.transcripts.forkWithProvenance(sourceRecord.claudeSessionId, boundary, sourceRecord.thread.cwd, sourceBoundaries.map((entry) => entry.messageUuid))
945
+ : { sessionId: uuidv7(), uuidMap: new Map() };
946
+ try {
947
+ const current = await this.sessions.submit(params.threadId, { type: "snapshotBranch" });
948
+ const currentTurns = current.record.thread.turns.filter((turn) => selectedIds.has(turn.id));
949
+ const currentBoundaries = current.boundaries.filter((entry) => selectedIds.has(entry.turnId));
950
+ const currentRevision = branchRevision(current.record, currentBoundaries, currentTurns);
951
+ if (currentRevision !== revision)
952
+ throw invalidParams("Claude thread changed while branch was being prepared; retry the operation.");
953
+ }
954
+ catch (error) {
955
+ await this.transcripts.delete(branch.sessionId, sourceRecord.thread.cwd).catch(() => undefined);
956
+ throw error;
957
+ }
624
958
  const createdAt = nowSeconds();
625
- const cwd = existingThreadCwd(params.cwd ?? source.thread.cwd);
959
+ const cwd = existingThreadCwd(params.cwd ?? sourceRecord.thread.cwd);
626
960
  const thread = {
627
- ...source.thread,
628
- id: uuidv7(),
629
- ephemeral: params.ephemeral ?? false,
630
- sessionId: source.thread.sessionId,
631
- forkedFromId: source.thread.id,
632
- cwd,
633
- modelProvider: "claude",
634
- createdAt,
635
- updatedAt: createdAt,
636
- recencyAt: createdAt,
637
- status: { type: "notLoaded" },
638
- name: source.thread.name ? `${source.thread.name} (fork)` : null,
639
- threadSource: params.threadSource ?? source.thread.threadSource,
640
- turns: [],
961
+ ...sourceRecord.thread, id: uuidv7(), ephemeral: params.ephemeral ?? false,
962
+ sessionId: sourceRecord.thread.sessionId, forkedFromId: sourceRecord.thread.id,
963
+ cwd, modelProvider: "claude", createdAt, updatedAt: createdAt, recencyAt: createdAt,
964
+ status: params.ephemeral ? { type: "idle" } : { type: "notLoaded" },
965
+ name: sourceRecord.thread.name ? `${sourceRecord.thread.name} (fork)` : null,
966
+ threadSource: params.threadSource ?? sourceRecord.thread.threadSource, turns: [],
641
967
  };
642
- const copiedTurns = sourceTurns.map(clonedTurn);
968
+ const copiedTurns = sourceTurns.map((turn) => forkProjection(turn, thread.id));
643
969
  const record = {
644
- ...source,
645
- thread,
646
- claudeSessionId: branch.sessionId,
647
- modelPickerId,
648
- claudeModelValue,
649
- serviceTier,
650
- approvalPolicy: params.approvalPolicy ?? source.approvalPolicy,
651
- approvalsReviewer: approvalsReviewer(params.approvalsReviewer, source.approvalsReviewer),
970
+ ...sourceRecord, thread, claudeSessionId: branch.sessionId, modelPickerId, claudeModelValue, serviceTier,
971
+ approvalPolicy: params.approvalPolicy ?? sourceRecord.approvalPolicy,
972
+ approvalsReviewer: approvalsReviewer(params.approvalsReviewer, sourceRecord.approvalsReviewer),
652
973
  sandboxPolicy: params.permissions
653
974
  ? permissionProfileSandboxPolicy(params.permissions, cwd)
654
- : params.sandbox
655
- ? sandboxPolicy(params.sandbox, cwd)
656
- : source.sandboxPolicy,
657
- baseInstructions: params.baseInstructions === undefined ? source.baseInstructions : params.baseInstructions,
658
- developerInstructions: params.developerInstructions === undefined ? source.developerInstructions : params.developerInstructions,
659
- lastClaudeMessageUuid: boundary ? branch.uuidMap.get(boundary) ?? null : null,
660
- lastCompletedTurnId: copiedTurns.at(-1)?.id ?? null,
975
+ : params.sandbox ? sandboxPolicy(params.sandbox, cwd) : sourceRecord.sandboxPolicy,
976
+ baseInstructions: params.baseInstructions === undefined ? sourceRecord.baseInstructions : params.baseInstructions,
977
+ developerInstructions: params.developerInstructions === undefined
978
+ ? sourceRecord.developerInstructions : params.developerInstructions,
979
+ lastClaudeMessageUuid: null, lastCompletedTurnId: null,
661
980
  };
662
- this.store.createThread(record);
663
- for (let index = 0; index < copiedTurns.length; index += 1) {
664
- const turn = copiedTurns[index];
665
- this.store.createTurn(thread.id, turn);
666
- const oldUuid = this.store.getTurnClaudeMessageUuid(source.thread.id, sourceTurns[index].id);
667
- const newUuid = oldUuid && branch.uuidMap.get(oldUuid);
668
- if (newUuid)
669
- this.store.setTurnClaudeMessageUuid(thread.id, turn.id, newUuid);
670
- }
671
- let responseRecord = { ...record, thread: { ...thread, turns: copiedTurns } };
981
+ let responseRecord;
982
+ try {
983
+ responseRecord = await this.sessions.submit(thread.id, {
984
+ type: "commitForkTarget", record, turns: copiedTurns,
985
+ sourceBoundaries, uuidMap: [...branch.uuidMap],
986
+ });
987
+ }
988
+ catch (error) {
989
+ await this.transcripts.delete(branch.sessionId, sourceRecord.thread.cwd).catch(() => undefined);
990
+ throw error;
991
+ }
992
+ responseRecord = { ...responseRecord, thread: { ...responseRecord.thread, turns: copiedTurns } };
672
993
  if (thread.ephemeral) {
673
994
  try {
674
- const runtime = await ClaudeSessionRuntime.create(responseRecord, this.store, this.hub, this.logger, this.config, boundary !== undefined, this.queryFactory, this.interactions, () => this.modelCatalog?.invalidate?.(), this.metrics);
675
- this.runtimes.set(thread.id, runtime);
676
- this.metrics.runtimeLoaded(boundary !== undefined);
677
- responseRecord = { ...responseRecord, thread: { ...responseRecord.thread, status: { type: "idle" } } };
678
- this.store.updateThread(responseRecord);
995
+ const targetSession = await this.sessions.getOrCreate(thread.id);
996
+ await targetSession.ensureRuntime();
997
+ const activeTurn = activeIndex >= 0 ? sourceRecord.thread.turns[activeIndex] : undefined;
998
+ if (activeSideFork && activeTurn) {
999
+ await targetSession.injectRuntimeItems([sideReferenceSnapshot(sourceRecord.thread.id, activeTurn)]);
1000
+ responseRecord = this.requireRecord(thread.id, true);
1001
+ }
679
1002
  }
680
1003
  catch (error) {
681
- this.store.deleteThread(thread.id);
682
- await deleteSession(branch.sessionId, { dir: cwd }).catch(() => undefined);
1004
+ await this.sessions.submit(thread.id, { type: "deleteBranchTarget" }).catch(() => undefined);
1005
+ await this.sessions.retire(thread.id).catch(() => undefined);
1006
+ await this.transcripts.delete(branch.sessionId, sourceRecord.thread.cwd).catch(() => undefined);
683
1007
  throw error;
684
1008
  }
685
1009
  }
686
- const response = threadResponse(responseRecord, !params.excludeTurns);
687
- return response;
1010
+ return threadResponse(responseRecord, !params.excludeTurns);
688
1011
  }
689
1012
  async rollbackThread(params) {
1013
+ this.requireIndependentThread(params.threadId, "roll back");
1014
+ const source = await this.sessions.submit(params.threadId, { type: "snapshotBranch" });
1015
+ const sourceRecord = source.record;
1016
+ if (sourceRecord.thread.ephemeral)
1017
+ throw invalidParams("Rolling back an ephemeral Claude thread is not supported.");
690
1018
  if (!Number.isInteger(params.numTurns) || params.numTurns < 1)
691
1019
  throw invalidParams("numTurns must be at least 1.");
692
- const source = this.requireRecord(params.threadId, true);
693
- if (source.thread.ephemeral)
694
- throw invalidParams("Rolling back an ephemeral Claude thread is not supported.");
695
- if (params.numTurns > source.thread.turns.length)
1020
+ if (params.numTurns > sourceRecord.thread.turns.length)
696
1021
  throw invalidParams("Cannot remove more turns than the Claude thread contains.");
697
- if (this.runtimes.get(params.threadId)?.isActive)
1022
+ const sourceSession = await this.sessions.getOrCreate(params.threadId);
1023
+ if ((await sourceSession.runtimeInspection())?.quiescent === false)
698
1024
  throw invalidParams("Cannot roll back a Claude thread with an active turn.");
699
- const keepCount = source.thread.turns.length - params.numTurns;
700
- const retained = source.thread.turns.slice(0, keepCount);
701
- const boundary = this.lastProviderBoundary(params.threadId, retained);
702
- const branch = await this.forkTranscript(source, boundary);
703
- await this.unloadRuntime(params.threadId, "Claude thread rolled back.");
704
- this.store.truncateTurns(params.threadId, keepCount);
705
- for (const turn of retained) {
706
- const oldUuid = this.store.getTurnClaudeMessageUuid(params.threadId, turn.id);
707
- const newUuid = oldUuid && branch.uuidMap.get(oldUuid);
708
- if (newUuid)
709
- this.store.setTurnClaudeMessageUuid(params.threadId, turn.id, newUuid);
710
- }
711
- const current = this.requireRecord(params.threadId, false);
712
- const updated = {
713
- ...current,
714
- claudeSessionId: branch.sessionId,
715
- lastClaudeMessageUuid: boundary ? branch.uuidMap.get(boundary) ?? null : null,
716
- lastCompletedTurnId: retained.at(-1)?.id ?? null,
717
- thread: { ...current.thread, status: { type: "notLoaded" }, updatedAt: nowSeconds(), recencyAt: nowSeconds() },
1025
+ const keepCount = sourceRecord.thread.turns.length - params.numTurns;
1026
+ const retained = sourceRecord.thread.turns.slice(0, keepCount);
1027
+ const retainedIds = new Set(retained.map((turn) => turn.id));
1028
+ const sourceBoundaries = source.boundaries.filter((entry) => retainedIds.has(entry.turnId));
1029
+ const boundary = sourceBoundaries.at(-1)?.messageUuid;
1030
+ const branch = boundary
1031
+ ? await this.transcripts.forkWithProvenance(sourceRecord.claudeSessionId, boundary, sourceRecord.thread.cwd, sourceBoundaries.map((entry) => entry.messageUuid))
1032
+ : { sessionId: uuidv7(), uuidMap: new Map() };
1033
+ let committed;
1034
+ try {
1035
+ committed = await this.sessions.submit(params.threadId, {
1036
+ type: "commitRollback", expectedRevision: source.revision, replacementSessionId: branch.sessionId,
1037
+ keepCount, sourceBoundaries, uuidMap: [...branch.uuidMap],
1038
+ });
1039
+ }
1040
+ catch (error) {
1041
+ await this.transcripts.delete(branch.sessionId, sourceRecord.thread.cwd).catch(() => undefined);
1042
+ throw error;
1043
+ }
1044
+ if (sourceSession.isLoaded) {
1045
+ await sourceSession.retireRuntimeSilently().catch((error) => {
1046
+ this.logger.warn("claude.rollback.old-runtime-retire-failed", { threadId: params.threadId, error: String(error) });
1047
+ });
1048
+ }
1049
+ await this.transcripts.delete(sourceRecord.claudeSessionId, sourceRecord.thread.cwd).catch((error) => this.logger.warn("claude.rollback.old-session-delete-failed", { threadId: params.threadId, error: String(error) }));
1050
+ return { thread: committed.thread };
1051
+ }
1052
+ prepareGoalSet(params) {
1053
+ this.requireIndependentThread(params.threadId, "set a goal on");
1054
+ return this.sessions.submit(params.threadId, { type: "goal", command: { kind: "prepareSet", params } }).then((mutation) => this.goalHandle(params.threadId, mutation));
1055
+ }
1056
+ async getGoal(threadId) {
1057
+ if (this.requireRecord(threadId, false).thread.parentThreadId)
1058
+ return { goal: null };
1059
+ const goal = await this.sessions.submit(threadId, { type: "goal", command: { kind: "get" } });
1060
+ return { goal: goal ? (({ goalId: _, ...value }) => value)(goal) : null };
1061
+ }
1062
+ prepareGoalClear(threadId) {
1063
+ this.requireIndependentThread(threadId, "clear a goal from");
1064
+ return this.sessions.submit(threadId, { type: "goal", command: { kind: "prepareClear" } }).then((mutation) => this.goalHandle(threadId, mutation));
1065
+ }
1066
+ goalHandle(threadId, mutation) {
1067
+ let notified;
1068
+ return {
1069
+ response: mutation.response,
1070
+ notify: () => notified ??= this.sessions.submit(threadId, { type: "goal", command: { kind: "finalize", mutation } }),
718
1071
  };
719
- this.store.updateThread(updated);
720
- await deleteSession(source.claudeSessionId, { dir: source.thread.cwd }).catch((error) => {
721
- this.logger.warn("claude.rollback.old-session-delete-failed", { threadId: params.threadId, error: String(error) });
1072
+ }
1073
+ async resolveServerRequest(requestId, response) {
1074
+ const request = this.store.getPendingRequest(requestId);
1075
+ if (!request)
1076
+ return false;
1077
+ let owner = this.requireRecord(request.threadId, false);
1078
+ while (owner.thread.parentThreadId)
1079
+ owner = this.requireRecord(owner.thread.parentThreadId, false);
1080
+ return this.sessions.submit(owner.thread.id, {
1081
+ type: "resolveInteraction",
1082
+ requestId,
1083
+ response,
722
1084
  });
723
- return { thread: { ...updated.thread, turns: retained } };
724
1085
  }
725
- setGoal(params) {
726
- this.requireRecord(params.threadId, false);
727
- const now = nowSeconds();
728
- const previous = this.currentGoal(params.threadId, now);
729
- const goal = {
730
- threadId: params.threadId,
731
- objective: params.objective ?? previous?.objective ?? "",
732
- status: params.status ?? previous?.status ?? "active",
733
- tokenBudget: params.tokenBudget === undefined ? previous?.tokenBudget ?? null : params.tokenBudget,
734
- tokensUsed: previous?.tokensUsed ?? 0,
735
- timeUsedSeconds: previous?.timeUsedSeconds ?? 0,
736
- createdAt: previous?.createdAt ?? now,
737
- updatedAt: now,
738
- };
739
- this.store.setGoal(goal);
740
- this.hub.emit(params.threadId, "thread/goal/updated", { threadId: params.threadId, turnId: null, goal });
741
- return { goal };
1086
+ replayPendingRequests(threadId, connectionId) {
1087
+ this.assertThreadAvailable(threadId);
1088
+ return this.sessions.submit(threadId, {
1089
+ type: "replayInteractions",
1090
+ ...(connectionId ? { connectionId } : {}),
1091
+ });
742
1092
  }
743
- getGoal(threadId) {
744
- this.requireRecord(threadId, false);
745
- return { goal: this.currentGoal(threadId, nowSeconds()) ?? null };
1093
+ eventHighWatermark(threadId) {
1094
+ this.assertThreadAvailable(threadId);
1095
+ return this.store.eventHighWatermark(threadId);
746
1096
  }
747
- clearGoal(threadId) {
748
- this.requireRecord(threadId, false);
749
- const cleared = this.store.clearGoal(threadId);
750
- if (cleared)
751
- this.hub.emit(threadId, "thread/goal/cleared", { threadId });
752
- return { cleared };
1097
+ eventsAfter(threadId, sequence) {
1098
+ this.assertThreadAvailable(threadId);
1099
+ return this.store.listEventsAfter(threadId, sequence);
753
1100
  }
754
- resolveServerRequest(requestId, response) {
755
- return this.interactions.resolve(requestId, response);
1101
+ latestTokenUsage(threadId) {
1102
+ this.assertThreadAvailable(threadId);
1103
+ const record = this.requireRecord(threadId, false);
1104
+ if (!record.tokenUsageLast)
1105
+ return undefined;
1106
+ const current = {
1107
+ threadId,
1108
+ turnId: record.lastCompletedTurnId,
1109
+ tokenUsage: {
1110
+ total: record.tokenUsageTotal,
1111
+ last: record.tokenUsageLast,
1112
+ modelContextWindow: record.modelContextWindow,
1113
+ },
1114
+ };
1115
+ const latest = this.store.listEventsAfter(threadId, 0)
1116
+ .filter((event) => event.method === "thread/tokenUsage/updated")
1117
+ .at(-1);
1118
+ const latestTurnId = latest?.params && typeof latest.params === "object" && "turnId" in latest.params
1119
+ ? latest.params.turnId
1120
+ : undefined;
1121
+ const replay = { ...current, turnId: typeof latestTurnId === "string" ? latestTurnId : current.turnId };
1122
+ return latest && JSON.stringify(latest.params) === JSON.stringify(replay)
1123
+ ? latest
1124
+ : { sequence: 0, threadId, turnId: replay.turnId, method: "thread/tokenUsage/updated", params: replay, createdAt: Date.now() };
756
1125
  }
757
- replayPendingRequests(threadId) {
758
- this.interactions.replay(threadId);
1126
+ close() {
1127
+ return this.closePromise ??= this.closeOnce();
759
1128
  }
760
- async close() {
1129
+ async closeOnce() {
1130
+ this.closing = true;
1131
+ const releaseResults = await Promise.allSettled(this.ephemeralReleases.values());
1132
+ await this.restartRecovery;
761
1133
  clearInterval(this.idleTimer);
762
1134
  await this.idleSweep;
763
- const runtimes = [...this.runtimes.values()];
764
- await Promise.all(runtimes.map((runtime) => runtime.stop("Gateway shut down during an active Claude turn.")));
765
- for (const _runtime of runtimes)
766
- this.metrics.runtimeUnloaded();
767
- this.runtimes.clear();
1135
+ await Promise.all(this.sessions.activeOwnerIds().map((threadId) => this.failStatusTurnForUnload(threadId, "Gateway restarted while the CCodex status request was active.")));
1136
+ await this.sessions.close();
768
1137
  this.store.close();
1138
+ const failures = releaseResults
1139
+ .filter((result) => result.status === "rejected")
1140
+ .map((result) => result.reason);
1141
+ if (failures.length > 0) {
1142
+ throw new AggregateError(failures, "Failed to release ephemeral Claude threads during shutdown.");
1143
+ }
769
1144
  }
770
1145
  async discardThreadSilently(threadId) {
771
- const record = this.store.getThreadRecord(threadId, false);
772
- if (!record)
1146
+ if (!this.store.getThreadRecord(threadId, false))
773
1147
  return;
774
- await this.unloadRuntime(threadId, "Internal compact handoff completed.");
775
- this.store.deleteThread(threadId);
776
- await deleteSession(record.claudeSessionId, { dir: record.thread.cwd }).catch(() => undefined);
1148
+ await this.removeThread(threadId, "discard", "Internal compact handoff completed.");
777
1149
  }
778
- reconcileAfterRestart() {
779
- for (const record of this.store.allThreadRecords()) {
780
- const crashed = record.thread.turns.filter((turn) => turn.status === "inProgress").map(failedAfterCrash);
781
- if (crashed.length > 0)
782
- this.interactions.cancelThread(record.thread.id);
783
- for (const turn of crashed) {
784
- this.store.appendEvent(record.thread.id, turn.id, "turn/completed", { threadId: record.thread.id, turn }, {
785
- turn, dedupKey: `turn/completed:${turn.id}`,
1150
+ async removeThread(threadId, kind, reason) {
1151
+ const pending = this.pendingThreadRemoval(threadId);
1152
+ if (pending) {
1153
+ if (pending.rootThreadId !== threadId || pending.kind !== kind)
1154
+ this.throwPendingRemoval(threadId, pending);
1155
+ return this.resumeThreadRemoval(threadId);
1156
+ }
1157
+ const existing = this.removalRetries.get(threadId);
1158
+ if (existing) {
1159
+ if (existing.kind !== kind)
1160
+ this.throwPendingRemoval(threadId, existing);
1161
+ return existing.promise;
1162
+ }
1163
+ const removal = this.startThreadRemoval(threadId, kind, reason)
1164
+ .finally(() => this.removalRetries.delete(threadId));
1165
+ this.removalRetries.set(threadId, { kind, promise: removal });
1166
+ return removal;
1167
+ }
1168
+ async startThreadRemoval(threadId, kind, reason) {
1169
+ const session = this.sessions.resolvedSession(threadId) ?? await this.sessions.getOrCreate(threadId);
1170
+ await session.withRuntimeAdmin(async () => {
1171
+ let prepared;
1172
+ let providerAttempted = false;
1173
+ try {
1174
+ prepared = await this.sessions.submit(threadId, {
1175
+ type: "threadAdmin",
1176
+ command: { kind: "beginRemoval", removalKind: kind },
1177
+ });
1178
+ await this.failStatusTurnForUnload(threadId, reason);
1179
+ await this.sessions.submit(threadId, {
1180
+ type: "goal",
1181
+ command: { kind: "detach", checkpoint: kind },
1182
+ });
1183
+ await session.retireRuntime(reason);
1184
+ providerAttempted = true;
1185
+ await this.deleteProviderSession(prepared.claudeSessionId, prepared.cwd);
1186
+ const committed = await this.sessions.submit(threadId, {
1187
+ type: "threadAdmin",
1188
+ command: { kind: "providerSucceeded", operationId: prepared.operationId },
1189
+ });
1190
+ if (!committed)
1191
+ throw new Error(`Stale Claude ${kind} completion for thread '${threadId}'.`);
1192
+ }
1193
+ catch (error) {
1194
+ if (prepared) {
1195
+ await this.sessions.submit(threadId, {
1196
+ type: "threadAdmin",
1197
+ command: {
1198
+ kind: "providerFailed",
1199
+ operationId: prepared.operationId,
1200
+ providerAttempted,
1201
+ },
1202
+ });
1203
+ }
1204
+ throw error;
1205
+ }
1206
+ });
1207
+ if (await session.mayRelease())
1208
+ await this.sessions.retire(threadId);
1209
+ }
1210
+ async threadAdminEffect(threadId, operation, effect, name) {
1211
+ const session = this.sessions.resolvedSession(threadId) ?? await this.sessions.getOrCreate(threadId);
1212
+ await session.withRuntimeAdmin(async () => {
1213
+ const prepared = await this.sessions.submit(threadId, {
1214
+ type: "threadAdmin",
1215
+ command: { kind: "prepare", operation, ...(name === undefined ? {} : { name }) },
1216
+ });
1217
+ try {
1218
+ await effect(prepared);
1219
+ }
1220
+ catch (error) {
1221
+ await this.sessions.submit(threadId, {
1222
+ type: "threadAdmin",
1223
+ command: { kind: "abort", operationId: prepared.operationId },
1224
+ }).catch(() => undefined);
1225
+ throw error;
1226
+ }
1227
+ try {
1228
+ const current = await this.sessions.submit(threadId, {
1229
+ type: "threadAdmin",
1230
+ command: { kind: "finish", operationId: prepared.operationId },
1231
+ });
1232
+ if (!current && operation !== "rename") {
1233
+ throw new Error(`Stale Claude ${operation} completion for thread '${threadId}'.`);
1234
+ }
1235
+ }
1236
+ catch (error) {
1237
+ await this.sessions.submit(threadId, {
1238
+ type: "threadAdmin",
1239
+ command: { kind: "abort", operationId: prepared.operationId },
1240
+ }).catch(() => undefined);
1241
+ throw error;
1242
+ }
1243
+ });
1244
+ if (await session.mayRelease())
1245
+ await this.sessions.retire(threadId);
1246
+ }
1247
+ async resumeThreadRemovals(rootThreadIds) {
1248
+ for (const rootThreadId of rootThreadIds) {
1249
+ try {
1250
+ await this.resumeThreadRemoval(rootThreadId);
1251
+ }
1252
+ catch (error) {
1253
+ this.logger.warn("claude.thread-removal.recovery-failed", {
1254
+ threadId: rootThreadId,
1255
+ error: String(error),
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+ async resumeThreadRemoval(rootThreadId) {
1261
+ const existing = this.removalRetries.get(rootThreadId);
1262
+ if (existing)
1263
+ return existing.promise;
1264
+ const kind = this.store.listPendingThreadRemovals()
1265
+ .find((removal) => removal.rootThreadId === rootThreadId)?.kind ?? "delete";
1266
+ const retry = this.resumeThreadRemovalOnce(rootThreadId)
1267
+ .finally(() => this.removalRetries.delete(rootThreadId));
1268
+ this.removalRetries.set(rootThreadId, { kind, promise: retry });
1269
+ return retry;
1270
+ }
1271
+ async resumeThreadRemovalOnce(rootThreadId) {
1272
+ const session = await this.sessions.getOrCreate(rootThreadId);
1273
+ await session.withRuntimeAdmin(async () => {
1274
+ let prepared;
1275
+ try {
1276
+ prepared = await this.sessions.submit(rootThreadId, {
1277
+ type: "threadAdmin",
1278
+ command: { kind: "recoverRemoval" },
1279
+ });
1280
+ await this.deleteProviderSession(prepared.claudeSessionId, prepared.cwd);
1281
+ const committed = await this.sessions.submit(rootThreadId, {
1282
+ type: "threadAdmin",
1283
+ command: { kind: "providerSucceeded", operationId: prepared.operationId },
786
1284
  });
787
- this.metrics.turnCompleted("failed");
1285
+ if (!committed) {
1286
+ throw new Error(`Stale Claude ${prepared.kind} recovery for thread '${rootThreadId}'.`);
1287
+ }
788
1288
  }
789
- const status = record.thread.status.type === "active" || crashed.length > 0
790
- ? { type: "systemError" }
791
- : { type: "notLoaded" };
792
- this.store.updateThread({
793
- ...record,
794
- ...(crashed.length > 0 ? { lastCompletedTurnId: crashed.at(-1).id } : {}),
795
- thread: { ...record.thread, status },
1289
+ catch (error) {
1290
+ if (prepared) {
1291
+ await this.sessions.submit(rootThreadId, {
1292
+ type: "threadAdmin",
1293
+ command: {
1294
+ kind: "providerFailed",
1295
+ operationId: prepared.operationId,
1296
+ providerAttempted: true,
1297
+ },
1298
+ });
1299
+ }
1300
+ throw error;
1301
+ }
1302
+ });
1303
+ await this.sessions.retire(rootThreadId).catch((error) => {
1304
+ this.logger.warn("claude.thread-removal.session-retire-failed", {
1305
+ threadId: rootThreadId,
1306
+ error: String(error),
796
1307
  });
1308
+ });
1309
+ }
1310
+ async deleteProviderSession(sessionId, cwd) {
1311
+ try {
1312
+ await this.threadAdminEffects.delete(sessionId, cwd);
1313
+ }
1314
+ catch (error) {
1315
+ if (!String(error).toLowerCase().includes("not found"))
1316
+ throw error;
1317
+ }
1318
+ }
1319
+ async reconcileAfterRestart(orphanProjectionIds, recoveryRootIds) {
1320
+ for (const threadId of orphanProjectionIds) {
1321
+ await this.sessions.submit(threadId, { type: "purgeStartupProjection" });
1322
+ await this.sessions.retire(threadId);
1323
+ }
1324
+ for (const threadId of recoveryRootIds) {
1325
+ try {
1326
+ const recovery = await this.sessions.submit(threadId, {
1327
+ type: "recoverAfterRestart",
1328
+ statusCommandEnabled: this.config.features?.statusCommand ?? true,
1329
+ });
1330
+ for (const eventType of recovery.abandonedProviderEventTypes) {
1331
+ this.metrics.providerEvent(eventType, "abandoned");
1332
+ }
1333
+ }
1334
+ finally {
1335
+ await this.sessions.retire(threadId);
1336
+ }
797
1337
  }
798
1338
  }
799
1339
  async unloadIdleRuntimes() {
800
1340
  const idleMs = this.config.idleTimeoutSeconds * 1_000;
801
- for (const [threadId, runtime] of this.runtimes) {
802
- if (runtime.isEphemeral || !runtime.isIdleFor(idleMs))
1341
+ for (const threadId of this.sessions.loadedOwnerIds()) {
1342
+ const session = await this.sessions.getOrCreate(threadId);
1343
+ const result = await session.retireRuntimeIfIdle(idleMs);
1344
+ if (result === "ephemeral") {
1345
+ await this.releaseEphemeralThread(threadId);
803
1346
  continue;
804
- this.runtimes.delete(threadId);
805
- await runtime.stop();
806
- this.metrics.runtimeUnloaded();
807
- this.logger.info("claude.runtime.unloaded", { threadId });
808
- }
809
- }
810
- async requireRuntime(threadId) {
811
- const loaded = this.runtimes.get(threadId);
812
- if (loaded)
813
- return loaded;
814
- await this.resumeThread(threadId);
815
- const runtime = this.runtimes.get(threadId);
816
- if (!runtime)
817
- throw new Error(`Failed to load Claude thread '${threadId}'.`);
818
- return runtime;
1347
+ }
1348
+ if (result === "retired") {
1349
+ this.logger.info("claude.runtime.unloaded", { threadId });
1350
+ await this.sessions.retire(threadId);
1351
+ }
1352
+ }
819
1353
  }
820
1354
  requireRecord(threadId, includeTurns) {
1355
+ this.assertThreadAvailable(threadId);
821
1356
  const record = this.store.getThreadRecord(threadId, includeTurns);
822
1357
  if (!record)
823
1358
  throw invalidParams(`Unknown Claude thread '${threadId}'.`);
824
1359
  return record;
825
1360
  }
826
- lastProviderBoundary(threadId, turns) {
827
- for (let index = turns.length - 1; index >= 0; index -= 1) {
828
- const boundary = this.store.getTurnClaudeMessageUuid(threadId, turns[index].id);
829
- if (boundary)
830
- return boundary;
1361
+ requireIndependentThread(threadId, operation) {
1362
+ const record = this.requireRecord(threadId, false);
1363
+ if (record.thread.parentThreadId) {
1364
+ throw invalidParams(`Cannot ${operation} Claude subagent thread '${threadId}'; it is a read-only projection.`);
831
1365
  }
832
- return undefined;
833
1366
  }
834
- async unloadRuntime(threadId, reason) {
835
- const runtime = this.runtimes.get(threadId);
836
- if (!runtime)
837
- return;
838
- this.runtimes.delete(threadId);
839
- await runtime.stop(reason);
840
- this.metrics.runtimeUnloaded();
1367
+ isChildProjection(threadId) {
1368
+ return Boolean(this.store.getThreadRecord(threadId, false)?.thread.parentThreadId);
841
1369
  }
842
- async applyTurnOverrides(params) {
843
- const record = this.requireRecord(params.threadId, false);
844
- const nextOutputSchema = params.outputSchema ?? null;
845
- const outputSchema = JSON.stringify(record.outputSchema) === JSON.stringify(nextOutputSchema)
846
- ? undefined
847
- : nextOutputSchema;
848
- await this.applySettings({
849
- threadId: params.threadId,
850
- ...(params.cwd !== undefined ? { cwd: params.cwd } : {}),
851
- ...(params.approvalPolicy !== undefined ? { approvalPolicy: params.approvalPolicy } : {}),
852
- ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
853
- ...(params.sandboxPolicy !== undefined ? { sandboxPolicy: params.sandboxPolicy } : {}),
854
- ...(params.permissions !== undefined ? { permissions: params.permissions } : {}),
855
- ...(params.model !== undefined ? { model: params.model } : {}),
856
- ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
857
- ...(params.effort !== undefined ? { effort: params.effort } : {}),
858
- ...(params.summary !== undefined ? { summary: params.summary } : {}),
859
- ...(params.collaborationMode !== undefined ? { collaborationMode: params.collaborationMode } : {}),
860
- ...(params.personality !== undefined ? { personality: params.personality } : {}),
861
- }, true, outputSchema);
862
- }
863
- async applySettings(params, turnOverride, outputSchema) {
864
- const before = this.requireRecord(params.threadId, false);
865
- if (params.permissions && params.sandboxPolicy) {
866
- throw invalidParams("Claude thread settings cannot combine permissions with sandboxPolicy.");
867
- }
868
- if (params.model && !resolveClaudeModel(this.config, params.model))
869
- throw invalidParams("Cannot switch a Claude thread to a Codex model.");
870
- if (params.effort && !["low", "medium", "high", "xhigh", "max"].includes(params.effort)) {
871
- throw invalidParams(`Unsupported Claude effort '${params.effort}'.`);
1370
+ assertThreadAvailable(threadId) {
1371
+ const pending = this.pendingThreadRemoval(threadId);
1372
+ if (pending)
1373
+ this.throwPendingRemoval(threadId, pending);
1374
+ }
1375
+ throwPendingRemoval(threadId, removal) {
1376
+ throw invalidParams(`Claude thread '${threadId}' is pending ${removal.kind}; retry the ${removal.kind} request after cleanup completes.`);
1377
+ }
1378
+ pendingThreadRemoval(threadId) {
1379
+ const removals = this.store.listPendingThreadRemovals();
1380
+ const pending = (rootThreadId) => {
1381
+ const durable = removals.find((removal) => removal.rootThreadId === rootThreadId);
1382
+ if (durable)
1383
+ return durable;
1384
+ const inFlight = this.removalRetries.get(rootThreadId);
1385
+ const record = inFlight && this.store.getThreadRecord(rootThreadId, false);
1386
+ return inFlight && record ? {
1387
+ rootThreadId,
1388
+ claudeSessionId: record.claudeSessionId,
1389
+ cwd: record.thread.cwd,
1390
+ kind: inFlight.kind,
1391
+ } : undefined;
1392
+ };
1393
+ const direct = pending(threadId);
1394
+ if (direct)
1395
+ return direct;
1396
+ let record = this.store.getThreadRecord(threadId, false);
1397
+ const seen = new Set();
1398
+ while (record?.thread.parentThreadId && !seen.has(record.thread.id)) {
1399
+ seen.add(record.thread.id);
1400
+ const parentId = record.thread.parentThreadId;
1401
+ const removal = pending(parentId);
1402
+ if (removal)
1403
+ return removal;
1404
+ record = this.store.getThreadRecord(parentId, false);
872
1405
  }
873
- const modelPickerId = params.model ?? before.modelPickerId;
874
- const claudeModelValue = resolveClaudeModel(this.config, modelPickerId);
875
- if (!claudeModelValue)
876
- throw invalidParams("Cannot switch a Claude thread to a Codex model.");
877
- const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier === undefined ? before.serviceTier : params.serviceTier);
878
- if (serviceTier !== null && serviceTier !== "default" && serviceTier !== "fast") {
1406
+ return undefined;
1407
+ }
1408
+ async newThreadRecord(params, settings) {
1409
+ const modelPickerId = params.model;
1410
+ const claudeModelValue = modelPickerId && resolveClaudeModel(this.config, modelPickerId);
1411
+ if (!modelPickerId || !claudeModelValue)
1412
+ throw invalidParams("Claude thread requires a configured Claude model or alias.");
1413
+ if (params.permissions && params.sandbox)
1414
+ throw invalidParams("Claude thread start cannot combine permissions with sandbox.");
1415
+ const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier);
1416
+ if (serviceTier !== null && serviceTier !== "default" && serviceTier !== "fast")
879
1417
  throw invalidParams(`Unsupported Claude service tier '${serviceTier}'.`);
880
- }
881
- const cwd = existingThreadCwd(params.cwd ?? before.thread.cwd);
882
- const selectedSandboxPolicy = params.permissions
883
- ? permissionProfileSandboxPolicy(params.permissions, cwd)
884
- : params.sandboxPolicy ?? before.sandboxPolicy;
885
- const candidate = {
886
- ...before,
887
- thread: { ...before.thread, cwd },
1418
+ const reasoningEffort = settings?.effort ?? null;
1419
+ await this.validateModelSettings(modelPickerId, reasoningEffort, serviceTier);
1420
+ const createdAt = nowSeconds();
1421
+ const cwd = existingThreadCwd(params.cwd ?? process.cwd());
1422
+ return {
1423
+ thread: {
1424
+ id: uuidv7(),
1425
+ extra: null,
1426
+ sessionId: uuidv7(),
1427
+ forkedFromId: null,
1428
+ parentThreadId: null,
1429
+ preview: "",
1430
+ ephemeral: params.ephemeral ?? false,
1431
+ historyMode: params.historyMode ?? "legacy",
1432
+ modelProvider: "claude",
1433
+ createdAt,
1434
+ updatedAt: createdAt,
1435
+ recencyAt: createdAt,
1436
+ status: { type: "idle" },
1437
+ path: null,
1438
+ cwd,
1439
+ cliVersion: "claude-code",
1440
+ source: "appServer",
1441
+ threadSource: params.threadSource ?? null,
1442
+ agentNickname: null,
1443
+ agentRole: null,
1444
+ gitInfo: null,
1445
+ name: null,
1446
+ turns: [],
1447
+ },
1448
+ claudeSessionId: uuidv7(),
888
1449
  modelPickerId,
889
1450
  claudeModelValue,
890
- approvalPolicy: params.approvalPolicy ?? before.approvalPolicy,
891
- approvalsReviewer: approvalsReviewer(params.approvalsReviewer, before.approvalsReviewer),
892
- sandboxPolicy: selectedSandboxPolicy,
893
1451
  serviceTier,
894
- reasoningEffort: params.effort === undefined ? before.reasoningEffort : params.effort,
895
- reasoningSummary: params.summary === undefined ? before.reasoningSummary : params.summary,
896
- collaborationMode: params.collaborationMode === undefined ? before.collaborationMode : params.collaborationMode,
897
- personality: params.personality === undefined ? before.personality : params.personality,
898
- outputSchema: outputSchema === undefined ? before.outputSchema : outputSchema,
1452
+ approvalPolicy: params.approvalPolicy ?? "on-request",
1453
+ approvalsReviewer: approvalsReviewer(params.approvalsReviewer),
1454
+ sandboxPolicy: params.permissions
1455
+ ? permissionProfileSandboxPolicy(params.permissions, cwd)
1456
+ : sandboxPolicy(params.sandbox, cwd),
1457
+ baseInstructions: params.baseInstructions ?? null,
1458
+ developerInstructions: params.developerInstructions ?? null,
1459
+ personality: settings?.personality ?? params.personality ?? null,
1460
+ resolvedModel: null,
1461
+ lastClaudeMessageUuid: null,
1462
+ lastCompletedTurnId: null,
1463
+ claudeCodeVersion: null,
1464
+ reasoningEffort,
1465
+ reasoningSummary: settings?.summary ?? null,
1466
+ collaborationMode: settings?.collaborationMode === undefined || settings.collaborationMode === null
1467
+ ? null
1468
+ : syncedCollaborationMode(settings.collaborationMode, modelPickerId, reasoningEffort),
1469
+ outputSchema: null,
1470
+ tokenUsageTotal: { totalTokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
1471
+ tokenUsageLast: null,
1472
+ modelContextWindow: null,
1473
+ providerCostUsdTotal: 0,
899
1474
  };
900
- await this.validateModelSettings(candidate.modelPickerId, candidate.reasoningEffort, candidate.serviceTier);
901
- const changed = cwd !== before.thread.cwd || [
902
- "modelPickerId", "claudeModelValue", "approvalPolicy", "approvalsReviewer", "sandboxPolicy", "serviceTier",
903
- "reasoningEffort", "reasoningSummary", "collaborationMode", "personality", "outputSchema",
904
- ].some((key) => JSON.stringify(candidate[key]) !== JSON.stringify(before[key]));
905
- if (!changed)
906
- return;
907
- const runtime = this.runtimes.get(params.threadId);
908
- if (runtime?.isActive)
909
- throw invalidParams("Cannot change Claude settings during an active turn.");
910
- if (before.thread.ephemeral && runtime && !runtime.canRestartEphemeral) {
911
- throw invalidParams("Cannot reconfigure an ephemeral Claude thread after model-visible input was submitted.");
1475
+ }
1476
+ async applyTurnOverrides(params) {
1477
+ await this.applySettings({ ...params, effort: turnReasoningEffort(params) }, params.outputSchema ?? null);
1478
+ }
1479
+ async applySettings(params, outputSchema) {
1480
+ if (params.permissions && params.sandboxPolicy)
1481
+ throw invalidParams("Claude thread settings cannot combine permissions with sandboxPolicy.");
1482
+ if (params.effort && !["low", "medium", "high", "xhigh", "max"].includes(params.effort))
1483
+ throw invalidParams(`Unsupported Claude effort '${params.effort}'.`);
1484
+ let update;
1485
+ const session = await this.sessions.getOrCreate(params.threadId);
1486
+ for (;;) {
1487
+ const before = await this.sessions.submit(params.threadId, { type: "readThread", includeTurns: false });
1488
+ const modelPickerId = params.model ?? before.modelPickerId;
1489
+ const claudeModelValue = resolveClaudeModel(this.config, modelPickerId);
1490
+ if (!claudeModelValue)
1491
+ throw invalidParams("Cannot switch a Claude thread to a Codex model.");
1492
+ const serviceTier = normalizeClaudeServiceTier(this.config, modelPickerId, params.serviceTier === undefined ? before.serviceTier : params.serviceTier);
1493
+ if (serviceTier !== null && serviceTier !== "default" && serviceTier !== "fast")
1494
+ throw invalidParams(`Unsupported Claude service tier '${serviceTier}'.`);
1495
+ const cwd = existingThreadCwd(params.cwd ?? before.thread.cwd);
1496
+ const reasoningEffort = params.effort === undefined ? before.reasoningEffort : params.effort;
1497
+ const collaborationMode = params.collaborationMode === undefined
1498
+ ? before.collaborationMode
1499
+ : params.collaborationMode;
1500
+ const candidate = {
1501
+ ...before,
1502
+ thread: { ...before.thread, cwd },
1503
+ modelPickerId,
1504
+ claudeModelValue,
1505
+ serviceTier,
1506
+ approvalPolicy: params.approvalPolicy ?? before.approvalPolicy,
1507
+ approvalsReviewer: approvalsReviewer(params.approvalsReviewer, before.approvalsReviewer),
1508
+ sandboxPolicy: params.permissions
1509
+ ? permissionProfileSandboxPolicy(params.permissions, cwd)
1510
+ : params.sandboxPolicy ?? before.sandboxPolicy,
1511
+ reasoningEffort,
1512
+ reasoningSummary: params.summary === undefined ? before.reasoningSummary : params.summary,
1513
+ collaborationMode: collaborationMode === null ? null : syncedCollaborationMode(collaborationMode, modelPickerId, reasoningEffort),
1514
+ personality: params.personality === undefined ? before.personality : params.personality,
1515
+ outputSchema: outputSchema === undefined ? before.outputSchema : outputSchema,
1516
+ };
1517
+ await this.validateModelSettings(modelPickerId, reasoningEffort, serviceTier);
1518
+ update = await this.sessions.submit(params.threadId, {
1519
+ type: "updateDesiredSettings",
1520
+ expectedGeneration: settingsGeneration(before),
1521
+ candidate,
1522
+ threadSettings: threadSettings(candidate),
1523
+ });
1524
+ if (!update.conflict)
1525
+ break;
1526
+ await update.retryAfter;
912
1527
  }
913
- await this.unloadRuntime(params.threadId, "Claude runtime reconfigured.");
914
- const record = this.requireRecord(params.threadId, false);
915
- const updated = {
916
- ...candidate,
917
- thread: { ...candidate.thread, status: record.thread.status, updatedAt: nowSeconds() },
918
- };
919
- this.store.updateThread(updated);
920
- let finalUpdated = updated;
921
- if (updated.thread.ephemeral) {
922
- const shouldResume = updated.lastClaudeMessageUuid !== null;
923
- let replacement;
1528
+ if (!update.changed)
1529
+ return;
1530
+ const updated = update.record;
1531
+ if (updated.thread.ephemeral && update.replacementId && update.replay) {
924
1532
  try {
925
- replacement = await ClaudeSessionRuntime.create(updated, this.store, this.hub, this.logger, this.config, shouldResume, this.queryFactory, this.interactions, () => this.modelCatalog?.invalidate?.(), this.metrics);
1533
+ await session.replaceEphemeralRuntime(update.replay, update.replacementId);
926
1534
  }
927
1535
  catch (error) {
928
- this.store.deleteThread(params.threadId);
929
- await deleteSession(updated.claudeSessionId, { dir: updated.thread.cwd }).catch(() => undefined);
1536
+ await this.discardThreadSilently(params.threadId).catch(() => undefined);
930
1537
  throw error;
931
1538
  }
932
- this.runtimes.set(params.threadId, replacement);
933
- this.metrics.runtimeLoaded(shouldResume);
934
- finalUpdated = { ...updated, thread: { ...updated.thread, status: { type: "idle" } } };
935
- this.store.updateThread(finalUpdated);
936
1539
  }
937
- const threadSettings = this.threadSettings(finalUpdated);
938
- this.hub.emit(params.threadId, "thread/settings/updated", { threadId: params.threadId, threadSettings });
939
- if (!turnOverride)
940
- this.logger.info("claude.thread.settings-updated", { threadId: params.threadId });
941
1540
  }
942
1541
  async validateModelSettings(modelId, effort, serviceTier) {
943
1542
  if (!this.modelCatalog)
@@ -953,56 +1552,65 @@ export class ClaudeService {
953
1552
  throw invalidParams(`Claude model '${modelId}' does not support the fast service tier.`);
954
1553
  }
955
1554
  }
956
- threadSettings(record) {
957
- return {
958
- cwd: record.thread.cwd,
959
- approvalPolicy: record.approvalPolicy,
960
- approvalsReviewer: record.approvalsReviewer,
961
- sandboxPolicy: record.sandboxPolicy,
962
- activePermissionProfile: null,
963
- model: record.modelPickerId,
964
- modelProvider: "claude",
965
- serviceTier: record.serviceTier,
966
- effort: record.reasoningEffort,
967
- summary: record.reasoningSummary,
968
- collaborationMode: record.collaborationMode ?? {
969
- mode: "default",
970
- settings: { model: record.modelPickerId, reasoning_effort: record.reasoningEffort, developer_instructions: null },
1555
+ async readStandaloneRateLimitStatus() {
1556
+ const abort = new AbortController();
1557
+ const sdkQuery = this.queryFactory({
1558
+ prompt: idleUsagePrompt(abort.signal),
1559
+ options: {
1560
+ pathToClaudeCodeExecutable: this.config.claudeBinary,
1561
+ persistSession: false,
1562
+ abortController: abort,
1563
+ allowedTools: [],
1564
+ settingSources: ["user", "project", "local"],
1565
+ env: process.env,
1566
+ stderr: (line) => this.logger.debug("claude.usage-probe.stderr", { output: line }),
971
1567
  },
972
- multiAgentMode: "explicitRequestOnly",
973
- personality: record.personality,
974
- };
1568
+ });
1569
+ let generation;
1570
+ let timer;
1571
+ try {
1572
+ const timeout = new Promise((_, reject) => {
1573
+ timer = setTimeout(() => reject(new Error("Claude usage probe timed out.")), 10_000);
1574
+ timer.unref();
1575
+ });
1576
+ await Promise.race([sdkQuery.initializationResult(), timeout]);
1577
+ generation = this.rateLimits.register(sdkQuery);
1578
+ return await this.rateLimits.readStatus();
1579
+ }
1580
+ finally {
1581
+ if (timer)
1582
+ clearTimeout(timer);
1583
+ if (generation !== undefined)
1584
+ this.rateLimits.unregister(generation);
1585
+ abort.abort();
1586
+ await Promise.resolve(sdkQuery.close()).catch(() => undefined);
1587
+ }
975
1588
  }
976
- emit(threadId, turnId, method, params) {
977
- this.store.appendEvent(threadId, turnId, method, params);
978
- if (method === "turn/completed" && params && typeof params === "object" && "turn" in params) {
979
- const turn = params.turn;
980
- if (turn.status !== "inProgress")
981
- this.metrics.turnCompleted(turn.status);
982
- }
983
- this.hub.emit(threadId, method, params);
984
- }
985
- currentGoal(threadId, now) {
986
- const goal = this.store.getGoal(threadId);
987
- if (!goal || goal.status !== "active")
988
- return goal;
989
- return { ...goal, timeUsedSeconds: goal.timeUsedSeconds + Math.max(0, now - goal.updatedAt), updatedAt: now };
990
- }
991
- async forkTranscript(source, boundary) {
992
- if (!boundary)
993
- return { sessionId: uuidv7(), uuidMap: new Map() };
994
- const original = await getSessionMessages(source.claudeSessionId, { dir: source.thread.cwd });
995
- const boundaryIndex = original.findIndex((message) => message.uuid === boundary);
996
- if (boundaryIndex < 0)
997
- throw new Error(`Claude transcript does not contain fork boundary '${boundary}'.`);
998
- const fork = await forkSession(source.claudeSessionId, { dir: source.thread.cwd, upToMessageId: boundary });
999
- const copied = await getSessionMessages(fork.sessionId, { dir: source.thread.cwd });
1000
- const prefix = original.slice(0, boundaryIndex + 1);
1001
- if (copied.length !== prefix.length || copied.some((message, index) => message.type !== prefix[index]?.type)) {
1002
- await deleteSession(fork.sessionId, { dir: source.thread.cwd }).catch(() => undefined);
1003
- throw new Error("Claude fork transcript could not be correlated with the source branch.");
1004
- }
1005
- return { sessionId: fork.sessionId, uuidMap: new Map(prefix.map((message, index) => [message.uuid, copied[index].uuid])) };
1589
+ async completeCommandTurn(threadId, turnId, synthetic, render) {
1590
+ let completion;
1591
+ try {
1592
+ completion = {
1593
+ type: "completeSynthetic", turnId,
1594
+ text: await render(),
1595
+ status: "completed", codexErrorInfo: null,
1596
+ };
1597
+ }
1598
+ catch (cause) {
1599
+ const message = cause instanceof Error ? cause.message : String(cause);
1600
+ completion = {
1601
+ type: "completeSynthetic", turnId,
1602
+ text: systemNoticeText(`${synthetic === "status" ? "Unable to read Claude usage" : "Unable to read thread state"}: ${message}`, "error"),
1603
+ status: "failed", errorMessage: message, codexErrorInfo: "internalServerError",
1604
+ };
1605
+ }
1606
+ await this.sessions.submit(threadId, completion).catch(() => undefined);
1607
+ }
1608
+ async failStatusTurnForUnload(threadId, message) {
1609
+ await this.sessions.submit(threadId, {
1610
+ type: "completeSynthetic", text: systemNoticeText(message, "error"),
1611
+ status: "failed", errorMessage: message,
1612
+ codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
1613
+ });
1006
1614
  }
1007
1615
  }
1008
1616
  //# sourceMappingURL=service.js.map