condukt 0.26.0 → 0.27.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/dist/runtimes/claude/bridge-cache.d.ts +58 -0
- package/dist/runtimes/claude/bridge-cache.d.ts.map +1 -0
- package/dist/runtimes/claude/bridge-cache.js +257 -0
- package/dist/runtimes/claude/bridge-cache.js.map +1 -0
- package/dist/runtimes/claude/claude-runtime.d.ts +30 -0
- package/dist/runtimes/claude/claude-runtime.d.ts.map +1 -0
- package/dist/runtimes/claude/claude-runtime.js +88 -0
- package/dist/runtimes/claude/claude-runtime.js.map +1 -0
- package/dist/runtimes/claude/claude-session.d.ts +154 -0
- package/dist/runtimes/claude/claude-session.d.ts.map +1 -0
- package/dist/runtimes/claude/claude-session.js +851 -0
- package/dist/runtimes/claude/claude-session.js.map +1 -0
- package/dist/runtimes/claude/config-mapper.d.ts +69 -0
- package/dist/runtimes/claude/config-mapper.d.ts.map +1 -0
- package/dist/runtimes/claude/config-mapper.js +458 -0
- package/dist/runtimes/claude/config-mapper.js.map +1 -0
- package/dist/runtimes/claude/epoch-store.d.ts +28 -0
- package/dist/runtimes/claude/epoch-store.d.ts.map +1 -0
- package/dist/runtimes/claude/epoch-store.js +199 -0
- package/dist/runtimes/claude/epoch-store.js.map +1 -0
- package/dist/runtimes/claude/fallback-runtime.d.ts +14 -0
- package/dist/runtimes/claude/fallback-runtime.d.ts.map +1 -0
- package/dist/runtimes/claude/fallback-runtime.js +64 -0
- package/dist/runtimes/claude/fallback-runtime.js.map +1 -0
- package/dist/runtimes/claude/index.d.ts +24 -0
- package/dist/runtimes/claude/index.d.ts.map +1 -0
- package/dist/runtimes/claude/index.js +61 -0
- package/dist/runtimes/claude/index.js.map +1 -0
- package/dist/runtimes/claude/message-mapper.d.ts +135 -0
- package/dist/runtimes/claude/message-mapper.d.ts.map +1 -0
- package/dist/runtimes/claude/message-mapper.js +504 -0
- package/dist/runtimes/claude/message-mapper.js.map +1 -0
- package/dist/runtimes/claude/one-shot.d.ts +48 -0
- package/dist/runtimes/claude/one-shot.d.ts.map +1 -0
- package/dist/runtimes/claude/one-shot.js +186 -0
- package/dist/runtimes/claude/one-shot.js.map +1 -0
- package/dist/runtimes/claude/protocol-bridge.d.ts +138 -0
- package/dist/runtimes/claude/protocol-bridge.d.ts.map +1 -0
- package/dist/runtimes/claude/protocol-bridge.js +1170 -0
- package/dist/runtimes/claude/protocol-bridge.js.map +1 -0
- package/dist/runtimes/claude/protocol-router.d.ts +113 -0
- package/dist/runtimes/claude/protocol-router.d.ts.map +1 -0
- package/dist/runtimes/claude/protocol-router.js +514 -0
- package/dist/runtimes/claude/protocol-router.js.map +1 -0
- package/dist/runtimes/claude/session-epoch.d.ts +156 -0
- package/dist/runtimes/claude/session-epoch.d.ts.map +1 -0
- package/dist/runtimes/claude/session-epoch.js +365 -0
- package/dist/runtimes/claude/session-epoch.js.map +1 -0
- package/dist/runtimes/claude/session-recovery.d.ts +78 -0
- package/dist/runtimes/claude/session-recovery.d.ts.map +1 -0
- package/dist/runtimes/claude/session-recovery.js +259 -0
- package/dist/runtimes/claude/session-recovery.js.map +1 -0
- package/dist/runtimes/claude/sse.d.ts +16 -0
- package/dist/runtimes/claude/sse.d.ts.map +1 -0
- package/dist/runtimes/claude/sse.js +46 -0
- package/dist/runtimes/claude/sse.js.map +1 -0
- package/dist/runtimes/claude/translate-chat.d.ts +106 -0
- package/dist/runtimes/claude/translate-chat.d.ts.map +1 -0
- package/dist/runtimes/claude/translate-chat.js +857 -0
- package/dist/runtimes/claude/translate-chat.js.map +1 -0
- package/dist/runtimes/claude/translate-responses.d.ts +76 -0
- package/dist/runtimes/claude/translate-responses.d.ts.map +1 -0
- package/dist/runtimes/claude/translate-responses.js +625 -0
- package/dist/runtimes/claude/translate-responses.js.map +1 -0
- package/dist/runtimes/claude/usage-ledger.d.ts +108 -0
- package/dist/runtimes/claude/usage-ledger.d.ts.map +1 -0
- package/dist/runtimes/claude/usage-ledger.js +189 -0
- package/dist/runtimes/claude/usage-ledger.js.map +1 -0
- package/dist/runtimes/copilot/sdk-backend.d.ts.map +1 -1
- package/dist/runtimes/copilot/sdk-backend.js +15 -104
- package/dist/runtimes/copilot/sdk-backend.js.map +1 -1
- package/dist/runtimes/mcp-env.d.ts +24 -0
- package/dist/runtimes/mcp-env.d.ts.map +1 -0
- package/dist/runtimes/mcp-env.js +81 -0
- package/dist/runtimes/mcp-env.js.map +1 -0
- package/dist/src/session-recovery-policy.d.ts +19 -0
- package/dist/src/session-recovery-policy.d.ts.map +1 -0
- package/dist/src/session-recovery-policy.js +43 -0
- package/dist/src/session-recovery-policy.js.map +1 -0
- package/dist/src/types.d.ts +45 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/package.json +20 -4
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ClaudeAgentSession = void 0;
|
|
4
|
+
const claude_agent_sdk_1 = require("@anthropic-ai/claude-agent-sdk");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const types_1 = require("../../src/types");
|
|
7
|
+
const session_recovery_policy_1 = require("../../src/session-recovery-policy");
|
|
8
|
+
const message_mapper_1 = require("./message-mapper");
|
|
9
|
+
const config_mapper_1 = require("./config-mapper");
|
|
10
|
+
const one_shot_1 = require("./one-shot");
|
|
11
|
+
const protocol_bridge_1 = require("./protocol-bridge");
|
|
12
|
+
const usage_ledger_1 = require("./usage-ledger");
|
|
13
|
+
const session_recovery_1 = require("./session-recovery");
|
|
14
|
+
const MCP_TOOL_PREFIX = 'mcp__';
|
|
15
|
+
class ClaudeAgentSession {
|
|
16
|
+
pid = null;
|
|
17
|
+
config;
|
|
18
|
+
abortController;
|
|
19
|
+
bridgeOptions;
|
|
20
|
+
bridgeHandle = null;
|
|
21
|
+
bridgeCreating = null;
|
|
22
|
+
handlers = [];
|
|
23
|
+
sessionId = null;
|
|
24
|
+
lastQuery = null;
|
|
25
|
+
aborted = false;
|
|
26
|
+
turnCount = 0;
|
|
27
|
+
sessionStartTime = Date.now();
|
|
28
|
+
lastCallContextTokens = 0;
|
|
29
|
+
lastCallInputTokens = 0;
|
|
30
|
+
lastCallOutputTokens = 0;
|
|
31
|
+
compactionCount = 0;
|
|
32
|
+
lastAssistantError;
|
|
33
|
+
lastApiRetry;
|
|
34
|
+
activeRecovery;
|
|
35
|
+
diagnostics = new Set();
|
|
36
|
+
includePartialMessages = true;
|
|
37
|
+
queryFn;
|
|
38
|
+
getSessionMessagesFn;
|
|
39
|
+
forkSessionFn;
|
|
40
|
+
sleepFn;
|
|
41
|
+
/**
|
|
42
|
+
* Usage is cumulative per session. A truncate forks the transcript, so the
|
|
43
|
+
* running totals restart from the fork point and the old baseline would
|
|
44
|
+
* suppress real usage until it caught up; the ledger is replaced instead.
|
|
45
|
+
*/
|
|
46
|
+
usageLedger;
|
|
47
|
+
// Serialize turns: Claude SDK sessions are single-flight; concurrent turns would
|
|
48
|
+
// interleave on the same abortController/sessionId.
|
|
49
|
+
turnChain = Promise.resolve();
|
|
50
|
+
constructor(config, options, bridgeOptions, deps) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.bridgeOptions = bridgeOptions;
|
|
53
|
+
this.abortController = new AbortController();
|
|
54
|
+
this.queryFn = deps?.queryFn ?? ((params) => (0, claude_agent_sdk_1.query)(params));
|
|
55
|
+
this.getSessionMessagesFn = deps?.getSessionMessagesFn
|
|
56
|
+
?? ((sessionId, opts) => (0, claude_agent_sdk_1.getSessionMessages)(sessionId, opts));
|
|
57
|
+
this.forkSessionFn = deps?.forkSessionFn
|
|
58
|
+
?? ((sessionId, opts) => (0, claude_agent_sdk_1.forkSession)(sessionId, opts));
|
|
59
|
+
this.sleepFn = deps?.sleepFn ?? ((ms) => this.sleep(ms));
|
|
60
|
+
this.usageLedger = new usage_ledger_1.ModelUsageLedger({ fallbackModel: config.model });
|
|
61
|
+
// Link to an externally provided abort signal (e.g. flow cancellation).
|
|
62
|
+
if (options?.signal) {
|
|
63
|
+
const sig = options.signal;
|
|
64
|
+
if (sig.aborted) {
|
|
65
|
+
this.aborted = true;
|
|
66
|
+
try {
|
|
67
|
+
this.abortController.abort();
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
sig.addEventListener('abort', () => {
|
|
73
|
+
this.aborted = true;
|
|
74
|
+
try {
|
|
75
|
+
this.abortController.abort();
|
|
76
|
+
}
|
|
77
|
+
catch { /* ignore */ }
|
|
78
|
+
}, { once: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
emit(event, ...args) {
|
|
83
|
+
for (const h of this.handlers) {
|
|
84
|
+
if (h.event === event) {
|
|
85
|
+
// The erased rest type is never[]; this event's payload was checked by
|
|
86
|
+
// the `on` overload at registration time.
|
|
87
|
+
try {
|
|
88
|
+
h.handler(...args);
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
diagnostic(message) {
|
|
95
|
+
if (this.diagnostics.has(message))
|
|
96
|
+
return;
|
|
97
|
+
this.diagnostics.add(message);
|
|
98
|
+
try {
|
|
99
|
+
process.stderr.write(`[ClaudeAgentSession] ${message}\n`);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* closed stream */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
sleep(ms) {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
if (this.aborted || this.abortController.signal.aborted) {
|
|
108
|
+
resolve();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let settled = false;
|
|
112
|
+
let timer;
|
|
113
|
+
const finish = () => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return;
|
|
116
|
+
settled = true;
|
|
117
|
+
if (timer !== undefined)
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
this.abortController.signal.removeEventListener('abort', finish);
|
|
120
|
+
resolve();
|
|
121
|
+
};
|
|
122
|
+
timer = setTimeout(finish, ms);
|
|
123
|
+
this.abortController.signal.addEventListener('abort', finish, { once: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
buildEmitter() {
|
|
127
|
+
return {
|
|
128
|
+
text: (text, parentToolCallId) => this.emit('text', text, parentToolCallId),
|
|
129
|
+
reasoning: (text) => this.emit('reasoning', text),
|
|
130
|
+
toolStart: (tool, input, args, callId, parentToolCallId) => this.emit('tool_start', tool, input, args, callId, parentToolCallId),
|
|
131
|
+
toolComplete: (tool, output, callId, parentToolCallId) => this.emit('tool_complete', tool, output, callId, parentToolCallId),
|
|
132
|
+
toolCompleteRich: (tool, contents, callId) => this.emit('tool_complete_rich', tool, contents, callId),
|
|
133
|
+
toolOutput: (tool, output, parentToolCallId) => this.emit('tool_output', tool, output, parentToolCallId),
|
|
134
|
+
idle: () => this.emit('idle'),
|
|
135
|
+
error: (err) => this.emit('error', err),
|
|
136
|
+
usage: (data) => {
|
|
137
|
+
const outputTokens = data.outputTokens;
|
|
138
|
+
if (typeof outputTokens === 'number')
|
|
139
|
+
this.lastCallOutputTokens = outputTokens;
|
|
140
|
+
this.emit('usage', data);
|
|
141
|
+
},
|
|
142
|
+
compaction: (phase, summary) => {
|
|
143
|
+
// Exact compaction advances the bridge's session era so the
|
|
144
|
+
// first post-compaction request already carries the fresh
|
|
145
|
+
// identity (cache namespace + routing affinity).
|
|
146
|
+
if (phase === 'complete') {
|
|
147
|
+
try {
|
|
148
|
+
this.bridgeHandle?.notifyCompaction();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* epoch identity must never fail a turn */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.emit('compaction', phase, summary);
|
|
155
|
+
},
|
|
156
|
+
subagentStart: (name, data) => this.emit('subagent_start', name, data),
|
|
157
|
+
subagentEnd: (name, data) => this.emit('subagent_end', name, data),
|
|
158
|
+
permission: (data) => this.emit('permission', data),
|
|
159
|
+
toolUsage: (data) => this.emit('tool_usage', data),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
send(prompt) {
|
|
163
|
+
const run = async () => {
|
|
164
|
+
// runTurn already emits 'error' on failure; do not re-emit here.
|
|
165
|
+
try {
|
|
166
|
+
await this.runTurn(prompt);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* already emitted by runTurn */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// Fire-and-forget but serialized against other turns.
|
|
173
|
+
this.turnChain = this.turnChain.then(run, run);
|
|
174
|
+
void this.turnChain.catch(() => { });
|
|
175
|
+
}
|
|
176
|
+
async continueSession(prompt) {
|
|
177
|
+
const run = () => this.runTurn(prompt, true);
|
|
178
|
+
const next = this.turnChain.then(run, run);
|
|
179
|
+
this.turnChain = next.catch(() => { });
|
|
180
|
+
await next;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Start (once) the in-process protocol bridge for this session.
|
|
184
|
+
*
|
|
185
|
+
* The bridge exposes an Anthropic-compatible surface and translates to
|
|
186
|
+
* the configured upstream protocol (Anthropic pass-through / OpenAI
|
|
187
|
+
* chat / OpenAI Responses). Returns null when no bridge is configured
|
|
188
|
+
* (the CLI then talks to its default Anthropic endpoint).
|
|
189
|
+
*/
|
|
190
|
+
async ensureBridge() {
|
|
191
|
+
if (!this.bridgeOptions)
|
|
192
|
+
return null;
|
|
193
|
+
if (this.aborted)
|
|
194
|
+
return null;
|
|
195
|
+
if (this.bridgeHandle)
|
|
196
|
+
return this.bridgeHandle;
|
|
197
|
+
if (!this.bridgeCreating) {
|
|
198
|
+
// Session-level epoch config (SessionConfig.claude.epoch) wins over
|
|
199
|
+
// the runtime-level bridge defaults.
|
|
200
|
+
const epoch = this.config.claude?.epoch;
|
|
201
|
+
const creating = (0, protocol_bridge_1.createBridge)({
|
|
202
|
+
...this.bridgeOptions,
|
|
203
|
+
epochEnabled: epoch?.enabled ?? this.bridgeOptions.epochEnabled,
|
|
204
|
+
epochRotateEnabled: epoch?.rotate ?? this.bridgeOptions.epochRotateEnabled,
|
|
205
|
+
epochStateFile: epoch?.stateFile ?? this.bridgeOptions.epochStateFile,
|
|
206
|
+
port: this.bridgeOptions.port ?? 0,
|
|
207
|
+
sessionId: this.bridgeOptions.sessionId ?? `condukt-${(0, node_crypto_1.randomUUID)()}`,
|
|
208
|
+
}).then((handle) => {
|
|
209
|
+
this.bridgeHandle = handle;
|
|
210
|
+
return handle;
|
|
211
|
+
}, (error) => {
|
|
212
|
+
// Do not cache a failed start: a later turn may recover (transient
|
|
213
|
+
// port contention) and would otherwise be stuck on this rejection.
|
|
214
|
+
if (this.bridgeCreating === creating)
|
|
215
|
+
this.bridgeCreating = null;
|
|
216
|
+
throw error;
|
|
217
|
+
});
|
|
218
|
+
this.bridgeCreating = creating;
|
|
219
|
+
}
|
|
220
|
+
return this.bridgeCreating;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Environment that points the Claude CLI at the bridge. Every query the
|
|
224
|
+
* session issues — turns, recovery resumes, handoff summaries, and the
|
|
225
|
+
* advisor/stand-in one-shots — must carry it, or the CLI falls back to
|
|
226
|
+
* `api.anthropic.com` where no credential exists under the bridge.
|
|
227
|
+
*/
|
|
228
|
+
async bridgeEnv() {
|
|
229
|
+
if (!this.bridgeOptions)
|
|
230
|
+
return undefined;
|
|
231
|
+
const bridge = await this.ensureBridge();
|
|
232
|
+
if (!bridge)
|
|
233
|
+
return undefined;
|
|
234
|
+
return (0, protocol_bridge_1.buildBridgeEnv)(bridge, { bridgeSecret: this.bridgeOptions.bridgeSecret });
|
|
235
|
+
}
|
|
236
|
+
async runTurn(prompt, resume = false) {
|
|
237
|
+
if (this.aborted)
|
|
238
|
+
throw new Error('Session is aborted');
|
|
239
|
+
this.turnCount += 1;
|
|
240
|
+
let bridgeEnv;
|
|
241
|
+
let mapped;
|
|
242
|
+
try {
|
|
243
|
+
bridgeEnv = await this.bridgeEnv();
|
|
244
|
+
mapped = (0, config_mapper_1.mapConfig)(this.config, {
|
|
245
|
+
...(bridgeEnv !== undefined ? { env: bridgeEnv } : {}),
|
|
246
|
+
onDiagnostic: (message) => this.diagnostic(message),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const err = normalizeError(error);
|
|
251
|
+
this.emit('error', err);
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
const mcpServer = (0, one_shot_1.buildAdvisorStandInServer)({
|
|
255
|
+
config: this.config,
|
|
256
|
+
cwd: this.config.cwd,
|
|
257
|
+
...(bridgeEnv !== undefined ? { env: bridgeEnv } : {}),
|
|
258
|
+
signal: this.abortController.signal,
|
|
259
|
+
onToolUsage: (data) => this.emit('tool_usage', data),
|
|
260
|
+
});
|
|
261
|
+
const mcpServers = mcpServer
|
|
262
|
+
? { ...(mapped.options.mcpServers ?? {}), [one_shot_1.CONDUKT_MCP_SERVER_NAME]: mcpServer }
|
|
263
|
+
: mapped.options.mcpServers;
|
|
264
|
+
// Under `dontAsk` a tool absent from `allowedTools` is denied. The
|
|
265
|
+
// advisor/stand-in server is registered after `mapConfig`, so it is not
|
|
266
|
+
// covered by the mapper's allow-list and must be added here.
|
|
267
|
+
let allowedTools = mapped.options.allowedTools;
|
|
268
|
+
if (mcpServer !== null && mapped.permissionMode === 'dontAsk') {
|
|
269
|
+
const merged = new Set(allowedTools ?? []);
|
|
270
|
+
for (const name of one_shot_1.INTERNAL_MCP_TOOL_NAMES)
|
|
271
|
+
merged.add(name);
|
|
272
|
+
allowedTools = [...merged];
|
|
273
|
+
}
|
|
274
|
+
const baseOptions = {
|
|
275
|
+
...mapped.options,
|
|
276
|
+
mcpServers,
|
|
277
|
+
allowedTools,
|
|
278
|
+
abortController: this.abortController,
|
|
279
|
+
includePartialMessages: this.includePartialMessages,
|
|
280
|
+
};
|
|
281
|
+
const requestedMode = mapped.permissionMode;
|
|
282
|
+
const readOnly = this.config.permissionPolicy === 'read-only' || (0, config_mapper_1.isSafeMode)();
|
|
283
|
+
const requestedTools = readOnly && Array.isArray(mapped.options.tools)
|
|
284
|
+
? mapped.options.tools
|
|
285
|
+
: undefined;
|
|
286
|
+
const initial = await this.attemptTurn(prompt, baseOptions, {
|
|
287
|
+
resume,
|
|
288
|
+
recovery: false,
|
|
289
|
+
requestedMode,
|
|
290
|
+
requestedTools,
|
|
291
|
+
});
|
|
292
|
+
if (this.aborted)
|
|
293
|
+
return;
|
|
294
|
+
if (initial.outcome !== undefined && !(0, session_recovery_1.outcomeIsFailure)(initial.outcome)) {
|
|
295
|
+
this.emit('idle');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
let failure;
|
|
299
|
+
if (initial.error !== undefined) {
|
|
300
|
+
failure = initial.error;
|
|
301
|
+
}
|
|
302
|
+
else if (initial.outcome !== undefined) {
|
|
303
|
+
failure = (0, session_recovery_1.failureFromOutcome)(initial.outcome);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let lastClassification = this.classifyFailure(initial);
|
|
309
|
+
const sessionId = this.sessionId;
|
|
310
|
+
const policy = (0, session_recovery_policy_1.effectiveSessionRecoveryPolicy)(this.config.sessionRecovery);
|
|
311
|
+
if (policy === undefined || !lastClassification.recoverable || sessionId === null) {
|
|
312
|
+
this.emit('error', failure);
|
|
313
|
+
throw failure;
|
|
314
|
+
}
|
|
315
|
+
const recoveryStartedAt = Date.now();
|
|
316
|
+
const deadlineMs = recoveryStartedAt + policy.budgetMs;
|
|
317
|
+
let lastFailure = failure;
|
|
318
|
+
let lastStage = 'continuation';
|
|
319
|
+
let continuation = 0;
|
|
320
|
+
// The SDK retries transient API failures natively before it ever reports
|
|
321
|
+
// one. Give that retry its grace window before resuming ourselves so the
|
|
322
|
+
// two mechanisms do not race.
|
|
323
|
+
if (this.lastApiRetry !== undefined) {
|
|
324
|
+
this.emitRecovery('native-retry', policy, 0, sessionId, recoveryStartedAt, {
|
|
325
|
+
...(this.lastApiRetry.status !== null ? { statusCode: this.lastApiRetry.status } : {}),
|
|
326
|
+
errorCode: this.lastApiRetry.error,
|
|
327
|
+
reason: `native retry ${this.lastApiRetry.attempt}/${this.lastApiRetry.maxRetries} did not recover`,
|
|
328
|
+
});
|
|
329
|
+
if (policy.nativeRetryGraceMs > 0) {
|
|
330
|
+
try {
|
|
331
|
+
await this.waitWithinDeadline(deadlineMs, policy.nativeRetryGraceMs);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
this.exhaustRecovery(policy, sessionId, continuation, lastFailure, recoveryStartedAt, 'recovery budget exhausted', 'continuation', lastClassification);
|
|
335
|
+
}
|
|
336
|
+
if (this.aborted)
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (;;) {
|
|
341
|
+
if (continuation >= policy.maxContinuations) {
|
|
342
|
+
this.exhaustRecovery(policy, sessionId, continuation, lastFailure, recoveryStartedAt, 'continuation limit exhausted', lastStage, lastClassification);
|
|
343
|
+
}
|
|
344
|
+
const delayMs = (0, session_recovery_policy_1.recoveryDelayMs)(policy, continuation);
|
|
345
|
+
this.emitRecovery('scheduled', policy, continuation + 1, sessionId, recoveryStartedAt, {
|
|
346
|
+
delayMs,
|
|
347
|
+
...recoveryMetadata(lastClassification),
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
await this.waitWithinDeadline(deadlineMs, delayMs);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
this.exhaustRecovery(policy, sessionId, continuation, lastFailure, recoveryStartedAt, 'recovery budget exhausted', lastStage, lastClassification);
|
|
354
|
+
}
|
|
355
|
+
if (this.aborted)
|
|
356
|
+
return;
|
|
357
|
+
continuation += 1;
|
|
358
|
+
// Audit before resuming: a missing transcript or session id cannot be
|
|
359
|
+
// fixed by sending the continuation, but a fresh audit may succeed on a
|
|
360
|
+
// later attempt, so it is retried within the same bounds.
|
|
361
|
+
try {
|
|
362
|
+
await this.auditRecoveryHistory(sessionId);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const auditError = normalizeError(error);
|
|
366
|
+
lastFailure = auditError;
|
|
367
|
+
lastStage = 'audit';
|
|
368
|
+
lastClassification = {
|
|
369
|
+
recoverable: true,
|
|
370
|
+
reason: auditError.message,
|
|
371
|
+
failureKind: 'audit',
|
|
372
|
+
};
|
|
373
|
+
if (continuation >= policy.maxContinuations || Date.now() >= deadlineMs) {
|
|
374
|
+
this.exhaustRecovery(policy, sessionId, continuation, lastFailure, recoveryStartedAt, `audit: ${auditError.message}`, 'audit', lastClassification);
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
this.emitRecovery('resuming', policy, continuation, sessionId, recoveryStartedAt, {
|
|
379
|
+
...recoveryMetadata(lastClassification),
|
|
380
|
+
});
|
|
381
|
+
this.activeRecovery = { policy, continuation, startedAt: recoveryStartedAt };
|
|
382
|
+
let attempt;
|
|
383
|
+
try {
|
|
384
|
+
attempt = await this.attemptTurn(session_recovery_1.CONTINUATION_PROMPT, baseOptions, {
|
|
385
|
+
resume: true,
|
|
386
|
+
recovery: true,
|
|
387
|
+
expectedSessionId: sessionId,
|
|
388
|
+
requestedMode,
|
|
389
|
+
requestedTools,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
this.activeRecovery = undefined;
|
|
394
|
+
}
|
|
395
|
+
if (this.aborted)
|
|
396
|
+
return;
|
|
397
|
+
if (attempt.outcome !== undefined && !(0, session_recovery_1.outcomeIsFailure)(attempt.outcome)) {
|
|
398
|
+
this.emitRecovery('recovered', policy, continuation, sessionId, recoveryStartedAt);
|
|
399
|
+
this.emit('idle');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const resumedFailure = attempt.error ?? (attempt.outcome !== undefined
|
|
403
|
+
? (0, session_recovery_1.failureFromOutcome)(attempt.outcome)
|
|
404
|
+
: undefined);
|
|
405
|
+
if (resumedFailure === undefined) {
|
|
406
|
+
// Aborted between attempts; abort() is the terminal signal.
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
lastFailure = resumedFailure;
|
|
410
|
+
lastClassification = this.classifyFailure(attempt);
|
|
411
|
+
if (attempt.error instanceof session_recovery_1.ClaudeResumeAttemptError) {
|
|
412
|
+
lastStage = attempt.error.stage;
|
|
413
|
+
if (attempt.error.fatal) {
|
|
414
|
+
this.exhaustRecovery(policy, sessionId, continuation, lastFailure, recoveryStartedAt, `${lastStage}: ${attempt.error.message}`, lastStage, lastClassification);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!lastClassification.recoverable) {
|
|
418
|
+
this.emit('error', resumedFailure);
|
|
419
|
+
throw resumedFailure;
|
|
420
|
+
}
|
|
421
|
+
// A resume that ran and failed again reconnects and nudges again; the
|
|
422
|
+
// continuation count and the recovery budget are what bound the loop.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* One query attempt. Returns the terminal outcome, or the error that ended
|
|
427
|
+
* the stream, instead of throwing so the recovery loop can classify it.
|
|
428
|
+
*/
|
|
429
|
+
async attemptTurn(prompt, baseOptions, context) {
|
|
430
|
+
if (this.aborted)
|
|
431
|
+
return {};
|
|
432
|
+
this.lastAssistantError = undefined;
|
|
433
|
+
this.lastApiRetry = undefined;
|
|
434
|
+
const options = context.resume && this.sessionId !== null
|
|
435
|
+
? { ...baseOptions, resume: this.sessionId }
|
|
436
|
+
: baseOptions;
|
|
437
|
+
let handle;
|
|
438
|
+
try {
|
|
439
|
+
handle = this.queryFn({ prompt, options });
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
const err = normalizeError(error);
|
|
443
|
+
return { error: context.recovery ? (0, session_recovery_1.toResumeAttemptError)(err, 'resume') : err };
|
|
444
|
+
}
|
|
445
|
+
this.lastQuery = handle;
|
|
446
|
+
const emitter = this.buildEmitter();
|
|
447
|
+
const state = (0, message_mapper_1.createMapState)();
|
|
448
|
+
const expectedSessionId = context.expectedSessionId
|
|
449
|
+
?? (context.resume ? this.sessionId ?? undefined : undefined);
|
|
450
|
+
let outcome;
|
|
451
|
+
let sawSessionId = false;
|
|
452
|
+
let continuationSent = false;
|
|
453
|
+
const sendContinuationEvent = () => {
|
|
454
|
+
if (!context.recovery || continuationSent)
|
|
455
|
+
return;
|
|
456
|
+
continuationSent = true;
|
|
457
|
+
const recovery = this.activeRecovery;
|
|
458
|
+
if (recovery === undefined)
|
|
459
|
+
return;
|
|
460
|
+
this.emitRecovery('continuation-sent', recovery.policy, recovery.continuation, expectedSessionId ?? this.sessionId ?? '', recovery.startedAt);
|
|
461
|
+
};
|
|
462
|
+
const mapOptions = {
|
|
463
|
+
includePartialMessages: this.includePartialMessages,
|
|
464
|
+
ledger: this.usageLedger,
|
|
465
|
+
resumed: context.resume && this.sessionId !== null,
|
|
466
|
+
fallbackModel: this.config.model,
|
|
467
|
+
onInit: (info) => {
|
|
468
|
+
this.verifyRequiredMode(info, context);
|
|
469
|
+
},
|
|
470
|
+
onResult: (result) => { outcome = result; },
|
|
471
|
+
onAssistantError: (error) => { this.lastAssistantError = error; },
|
|
472
|
+
onApiRetry: (info) => { this.lastApiRetry = info; },
|
|
473
|
+
onAssistantUsage: (contextTokens, outputTokens) => {
|
|
474
|
+
this.lastCallContextTokens = contextTokens;
|
|
475
|
+
this.lastCallInputTokens = contextTokens;
|
|
476
|
+
if (outputTokens > 0)
|
|
477
|
+
this.lastCallOutputTokens = outputTokens;
|
|
478
|
+
},
|
|
479
|
+
onCompaction: () => { this.compactionCount += 1; },
|
|
480
|
+
onCompactionFailed: (error) => {
|
|
481
|
+
if (error !== undefined)
|
|
482
|
+
this.diagnostic(`Compaction failed: ${error}`);
|
|
483
|
+
},
|
|
484
|
+
isAborted: () => this.aborted,
|
|
485
|
+
};
|
|
486
|
+
try {
|
|
487
|
+
for await (const msg of handle) {
|
|
488
|
+
if (this.aborted)
|
|
489
|
+
break;
|
|
490
|
+
const frame = msg;
|
|
491
|
+
const frameSessionId = typeof frame.session_id === 'string' && frame.session_id.length > 0
|
|
492
|
+
? frame.session_id
|
|
493
|
+
: undefined;
|
|
494
|
+
if (frameSessionId !== undefined && !sawSessionId) {
|
|
495
|
+
sawSessionId = true;
|
|
496
|
+
if (expectedSessionId !== undefined && frameSessionId !== expectedSessionId) {
|
|
497
|
+
throw context.recovery
|
|
498
|
+
? new session_recovery_1.ClaudeResumeAttemptError('resume', `Claude resumed unexpected session '${frameSessionId}' instead of '${expectedSessionId}'`, true)
|
|
499
|
+
: new Error(`Claude returned session '${frameSessionId}' for a turn that resumed '${expectedSessionId}'`);
|
|
500
|
+
}
|
|
501
|
+
sendContinuationEvent();
|
|
502
|
+
}
|
|
503
|
+
if (frameSessionId !== undefined && this.sessionId === null) {
|
|
504
|
+
this.sessionId = frameSessionId;
|
|
505
|
+
}
|
|
506
|
+
(0, message_mapper_1.mapSdkMessage)(msg, emitter, state, mapOptions);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (this.aborted)
|
|
511
|
+
return {};
|
|
512
|
+
const err = normalizeError(error);
|
|
513
|
+
if (err instanceof session_recovery_1.ClaudeConfigurationError || err instanceof session_recovery_1.ClaudeResumeAttemptError) {
|
|
514
|
+
return { error: err };
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
error: context.recovery
|
|
518
|
+
? (0, session_recovery_1.toResumeAttemptError)(err, sawSessionId ? 'continuation' : 'resume')
|
|
519
|
+
: err,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (this.aborted)
|
|
523
|
+
return {};
|
|
524
|
+
if (outcome === undefined) {
|
|
525
|
+
return {
|
|
526
|
+
error: context.recovery
|
|
527
|
+
? new session_recovery_1.ClaudeResumeAttemptError('continuation', new session_recovery_1.ClaudeStreamEndedError().message, false)
|
|
528
|
+
: new session_recovery_1.ClaudeStreamEndedError(),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// A resumed turn whose frames carried no session id still completed;
|
|
532
|
+
// report the dispatch before the terminal decision.
|
|
533
|
+
if (!continuationSent)
|
|
534
|
+
sendContinuationEvent();
|
|
535
|
+
return { outcome };
|
|
536
|
+
}
|
|
537
|
+
classifyFailure(attempt) {
|
|
538
|
+
return (0, session_recovery_1.classifyClaudeFailure)({
|
|
539
|
+
...(attempt.outcome !== undefined ? { outcome: attempt.outcome } : {}),
|
|
540
|
+
...(this.lastAssistantError !== undefined
|
|
541
|
+
? { assistantError: this.lastAssistantError }
|
|
542
|
+
: {}),
|
|
543
|
+
...(attempt.error !== undefined ? { error: attempt.error } : {}),
|
|
544
|
+
aborted: this.aborted,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* `requireMode` is a contract, not a hint: read the mode and the built-in
|
|
549
|
+
* tool set back from `init` before the first prompt and fail the turn when
|
|
550
|
+
* the CLI applied something else.
|
|
551
|
+
*/
|
|
552
|
+
verifyRequiredMode(info, context) {
|
|
553
|
+
if (this.config.requireMode !== true)
|
|
554
|
+
return;
|
|
555
|
+
if (context.requestedMode !== undefined && info.permissionMode !== context.requestedMode) {
|
|
556
|
+
throw new session_recovery_1.ClaudeConfigurationError(`Claude reported permission mode '${info.permissionMode}', but '${context.requestedMode}' was required`);
|
|
557
|
+
}
|
|
558
|
+
if (context.requestedTools !== undefined) {
|
|
559
|
+
const allowed = new Set(context.requestedTools);
|
|
560
|
+
const unexpected = info.tools
|
|
561
|
+
.filter((tool) => !tool.startsWith(MCP_TOOL_PREFIX))
|
|
562
|
+
.filter((tool) => !allowed.has(tool));
|
|
563
|
+
if (unexpected.length > 0) {
|
|
564
|
+
throw new session_recovery_1.ClaudeConfigurationError(`Claude enabled tools the required policy forbids: ${unexpected.join(', ')}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* A resumed session must still have a transcript to resume from. Pending
|
|
570
|
+
* `tool_use` blocks without results are fine: the CLI re-runs interrupted
|
|
571
|
+
* turns on resume (`resume_reason` frames).
|
|
572
|
+
*/
|
|
573
|
+
async auditRecoveryHistory(sessionId) {
|
|
574
|
+
const messages = await this.getSessionMessagesFn(sessionId, { dir: this.config.cwd });
|
|
575
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
576
|
+
throw new Error(`No transcript found for session '${sessionId}'`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async waitWithinDeadline(deadlineMs, delayMs) {
|
|
580
|
+
if (Date.now() + delayMs >= deadlineMs) {
|
|
581
|
+
throw new Error('Session recovery deadline exhausted');
|
|
582
|
+
}
|
|
583
|
+
await this.sleepFn(delayMs);
|
|
584
|
+
}
|
|
585
|
+
emitRecovery(phase, policy, continuation, sessionId, startedAt, fields = {}) {
|
|
586
|
+
this.emit('recovery', {
|
|
587
|
+
phase,
|
|
588
|
+
continuation,
|
|
589
|
+
maxContinuations: policy.maxContinuations,
|
|
590
|
+
sessionId,
|
|
591
|
+
...(startedAt !== undefined ? { elapsedMs: Date.now() - startedAt } : {}),
|
|
592
|
+
...fields,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Ends recovery. `stage` names the reconnect step whose failure ended it;
|
|
597
|
+
* the reason alone does not say which step produced it.
|
|
598
|
+
*/
|
|
599
|
+
exhaustRecovery(policy, sessionId, continuation, cause, startedAt, reason, stage, classification) {
|
|
600
|
+
this.activeRecovery = undefined;
|
|
601
|
+
this.emitRecovery('exhausted', policy, continuation, sessionId, startedAt, {
|
|
602
|
+
reason,
|
|
603
|
+
stage,
|
|
604
|
+
...recoveryMetadata(classification),
|
|
605
|
+
});
|
|
606
|
+
const error = new types_1.SessionRecoveryExhaustedError(`Session recovery exhausted after ${continuation} continuation(s): ${reason}`, { cause });
|
|
607
|
+
this.emit('error', error);
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
get metadata() {
|
|
611
|
+
const self = this;
|
|
612
|
+
return {
|
|
613
|
+
async getContextAttribution() {
|
|
614
|
+
const compactions = { count: self.compactionCount };
|
|
615
|
+
if (self.lastQuery?.getContextUsage) {
|
|
616
|
+
try {
|
|
617
|
+
const usage = await self.lastQuery.getContextUsage({ detail: 'summary' });
|
|
618
|
+
const entries = (usage.categories ?? []).map((category) => ({
|
|
619
|
+
kind: category.kind ?? 'used',
|
|
620
|
+
id: category.id ?? category.name ?? 'context',
|
|
621
|
+
label: category.name ?? category.id ?? 'Context',
|
|
622
|
+
tokens: nonNegative(category.tokens),
|
|
623
|
+
}));
|
|
624
|
+
if (entries.length > 0) {
|
|
625
|
+
return { totalTokens: nonNegative(usage.totalTokens), entries, compactions };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// Control requests are only supported in streaming input mode;
|
|
630
|
+
// this session uses string prompts, so failure is expected.
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (self.lastCallContextTokens <= 0)
|
|
634
|
+
return null;
|
|
635
|
+
// Prompt size of the latest assistant frame: an estimate of the
|
|
636
|
+
// current context window, not cumulative input across turns.
|
|
637
|
+
return {
|
|
638
|
+
totalTokens: self.lastCallContextTokens,
|
|
639
|
+
entries: [{
|
|
640
|
+
kind: 'used',
|
|
641
|
+
id: 'context',
|
|
642
|
+
label: 'Context',
|
|
643
|
+
tokens: self.lastCallContextTokens,
|
|
644
|
+
}],
|
|
645
|
+
compactions,
|
|
646
|
+
};
|
|
647
|
+
},
|
|
648
|
+
async getContextHeaviestMessages() {
|
|
649
|
+
// The SDK exposes no per-message tokenization.
|
|
650
|
+
return { totalTokens: self.lastCallContextTokens, messages: [] };
|
|
651
|
+
},
|
|
652
|
+
async recomputeContextTokens() {
|
|
653
|
+
return {
|
|
654
|
+
totalTokens: self.lastCallContextTokens,
|
|
655
|
+
messagesTokenCount: self.lastCallContextTokens,
|
|
656
|
+
systemTokenCount: 0,
|
|
657
|
+
};
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
get usage() {
|
|
662
|
+
const self = this;
|
|
663
|
+
return {
|
|
664
|
+
async getMetrics() {
|
|
665
|
+
const modelMetrics = {};
|
|
666
|
+
const snapshot = self.usageLedger.getSnapshot();
|
|
667
|
+
const requestCounts = self.usageLedger.getRequestCounts();
|
|
668
|
+
for (const [model, counts] of snapshot) {
|
|
669
|
+
modelMetrics[model] = {
|
|
670
|
+
// Per model: a turn that billed another model was not a request
|
|
671
|
+
// this model served.
|
|
672
|
+
requests: { count: requestCounts.get(model) ?? 0, cost: 0 },
|
|
673
|
+
usage: {
|
|
674
|
+
inputTokens: counts.inputTokens,
|
|
675
|
+
outputTokens: counts.outputTokens,
|
|
676
|
+
cacheReadTokens: counts.cacheReadTokens,
|
|
677
|
+
cacheWriteTokens: counts.cacheWriteTokens,
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (Object.keys(modelMetrics).length === 0) {
|
|
682
|
+
// No billed result yet: keep a zero-valued bucket for the
|
|
683
|
+
// configured model so consumers never see an empty map. With no
|
|
684
|
+
// usage the model split is unknown, so the session turn count is
|
|
685
|
+
// the only defensible number.
|
|
686
|
+
modelMetrics[self.config.model] = {
|
|
687
|
+
requests: { count: self.turnCount, cost: 0 },
|
|
688
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
totalPremiumRequestCost: 0,
|
|
693
|
+
totalUserRequests: self.turnCount,
|
|
694
|
+
// The SDK reports per-turn wall-clock duration, never API time;
|
|
695
|
+
// modelUsage carries no USD cost.
|
|
696
|
+
totalApiDurationMs: 0,
|
|
697
|
+
sessionStartTime: new Date(self.sessionStartTime).toISOString(),
|
|
698
|
+
codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0, filesModified: [] },
|
|
699
|
+
modelMetrics,
|
|
700
|
+
currentModel: self.config.model,
|
|
701
|
+
lastCallInputTokens: self.lastCallInputTokens,
|
|
702
|
+
lastCallOutputTokens: self.lastCallOutputTokens,
|
|
703
|
+
};
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
get history() {
|
|
708
|
+
const self = this;
|
|
709
|
+
return {
|
|
710
|
+
async summarizeForHandoff() {
|
|
711
|
+
const fallback = self.sessionId
|
|
712
|
+
? `Session ${self.sessionId}: ${self.lastCallInputTokens} input / ${self.lastCallOutputTokens} output tokens.`
|
|
713
|
+
: 'Session (no id): no usage recorded.';
|
|
714
|
+
if (!self.sessionId)
|
|
715
|
+
return fallback;
|
|
716
|
+
try {
|
|
717
|
+
const messages = await self.getSessionMessagesFn(self.sessionId, { dir: self.config.cwd });
|
|
718
|
+
const transcript = transcriptText(messages).slice(0, 8000);
|
|
719
|
+
if (transcript.trim().length === 0)
|
|
720
|
+
return fallback;
|
|
721
|
+
const bridgeEnv = await self.bridgeEnv();
|
|
722
|
+
const result = await (0, one_shot_1.runOneShot)({
|
|
723
|
+
model: self.config.model,
|
|
724
|
+
prompt: 'Summarize this conversation for handoff. Capture the objective, what was '
|
|
725
|
+
+ `completed, what remains, and any constraints that must survive a restart.\n\n${transcript}`,
|
|
726
|
+
cwd: self.config.cwd,
|
|
727
|
+
...(bridgeEnv !== undefined ? { env: bridgeEnv } : {}),
|
|
728
|
+
signal: self.abortController.signal,
|
|
729
|
+
});
|
|
730
|
+
return result.text || fallback;
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
return fallback;
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
async truncate(eventId) {
|
|
737
|
+
// Serialized with turns: forking mid-turn would race the transcript.
|
|
738
|
+
const run = () => self.truncateNow(eventId);
|
|
739
|
+
const next = self.turnChain.then(run, run);
|
|
740
|
+
self.turnChain = next.then(() => undefined, () => undefined);
|
|
741
|
+
return next;
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
async truncateNow(eventId) {
|
|
746
|
+
const sessionId = this.sessionId;
|
|
747
|
+
if (sessionId === null) {
|
|
748
|
+
throw new Error('Cannot truncate a Claude session that has not produced a session id');
|
|
749
|
+
}
|
|
750
|
+
if (this.aborted)
|
|
751
|
+
throw new Error('Session is aborted');
|
|
752
|
+
const messages = await this.getSessionMessagesFn(sessionId, { dir: this.config.cwd });
|
|
753
|
+
const index = messages.findIndex((message) => message.uuid === eventId);
|
|
754
|
+
if (index < 0) {
|
|
755
|
+
throw new Error(`Unknown history event '${eventId}'`);
|
|
756
|
+
}
|
|
757
|
+
if (index === 0) {
|
|
758
|
+
// `upToMessageId` is inclusive, so there is no way to fork to an empty
|
|
759
|
+
// transcript. Keep the session and report that nothing was removed.
|
|
760
|
+
this.diagnostic(`truncate('${eventId}') targets the first message; session unchanged`);
|
|
761
|
+
return 0;
|
|
762
|
+
}
|
|
763
|
+
const upToMessageId = messages[index - 1]?.uuid;
|
|
764
|
+
const forked = await this.forkSessionFn(sessionId, {
|
|
765
|
+
dir: this.config.cwd,
|
|
766
|
+
...(upToMessageId !== undefined ? { upToMessageId } : {}),
|
|
767
|
+
});
|
|
768
|
+
this.sessionId = forked.sessionId;
|
|
769
|
+
this.lastQuery = null;
|
|
770
|
+
// The fork restarts the cumulative usage total from its transcript; a
|
|
771
|
+
// fresh ledger seeds its baseline on the next resumed result instead of
|
|
772
|
+
// re-billing (or suppressing) the retained turns.
|
|
773
|
+
this.usageLedger = new usage_ledger_1.ModelUsageLedger({ fallbackModel: this.config.model });
|
|
774
|
+
return messages.length - index;
|
|
775
|
+
}
|
|
776
|
+
on(event, handler) {
|
|
777
|
+
this.handlers.push({ event, handler });
|
|
778
|
+
}
|
|
779
|
+
async abort() {
|
|
780
|
+
this.aborted = true;
|
|
781
|
+
this.activeRecovery = undefined;
|
|
782
|
+
try {
|
|
783
|
+
this.abortController.abort();
|
|
784
|
+
}
|
|
785
|
+
catch { /* ignore */ }
|
|
786
|
+
try {
|
|
787
|
+
this.lastQuery?.close?.();
|
|
788
|
+
}
|
|
789
|
+
catch { /* ignore */ }
|
|
790
|
+
try {
|
|
791
|
+
await this.bridgeCreating;
|
|
792
|
+
}
|
|
793
|
+
catch { /* bridge never started */ }
|
|
794
|
+
try {
|
|
795
|
+
await this.bridgeHandle?.close();
|
|
796
|
+
}
|
|
797
|
+
catch { /* ignore */ }
|
|
798
|
+
// Both references go: a session that has been aborted must not answer a
|
|
799
|
+
// later ensureBridge() with a closed handle.
|
|
800
|
+
this.bridgeCreating = null;
|
|
801
|
+
this.bridgeHandle = null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
exports.ClaudeAgentSession = ClaudeAgentSession;
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
// Helpers
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
function normalizeError(value) {
|
|
809
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
810
|
+
}
|
|
811
|
+
function nonNegative(value) {
|
|
812
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
813
|
+
}
|
|
814
|
+
function recoveryMetadata(classification) {
|
|
815
|
+
return {
|
|
816
|
+
...(classification.statusCode !== undefined ? { statusCode: classification.statusCode } : {}),
|
|
817
|
+
...(classification.errorCode !== undefined ? { errorCode: classification.errorCode } : {}),
|
|
818
|
+
...(classification.failureKind !== undefined ? { failureKind: classification.failureKind } : {}),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function asRecord(value) {
|
|
822
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
823
|
+
? value
|
|
824
|
+
: undefined;
|
|
825
|
+
}
|
|
826
|
+
/** Extracts readable text from `SessionMessage.message` content blocks. */
|
|
827
|
+
function transcriptText(messages) {
|
|
828
|
+
const lines = [];
|
|
829
|
+
for (const entry of messages) {
|
|
830
|
+
const message = asRecord(entry.message);
|
|
831
|
+
const role = typeof message?.role === 'string' ? message.role : entry.type;
|
|
832
|
+
const content = message?.content;
|
|
833
|
+
let text = '';
|
|
834
|
+
if (typeof content === 'string') {
|
|
835
|
+
text = content;
|
|
836
|
+
}
|
|
837
|
+
else if (Array.isArray(content)) {
|
|
838
|
+
text = content
|
|
839
|
+
.map((block) => {
|
|
840
|
+
const record = asRecord(block);
|
|
841
|
+
return record?.type === 'text' && typeof record.text === 'string' ? record.text : '';
|
|
842
|
+
})
|
|
843
|
+
.filter((part) => part.length > 0)
|
|
844
|
+
.join('\n');
|
|
845
|
+
}
|
|
846
|
+
if (text.trim().length > 0)
|
|
847
|
+
lines.push(`${role}: ${text}`);
|
|
848
|
+
}
|
|
849
|
+
return lines.join('\n');
|
|
850
|
+
}
|
|
851
|
+
//# sourceMappingURL=claude-session.js.map
|