@gkorepanov/ccodex 0.3.0 → 0.4.0

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