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