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