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.
Files changed (83) hide show
  1. package/dist/runtimes/claude/bridge-cache.d.ts +58 -0
  2. package/dist/runtimes/claude/bridge-cache.d.ts.map +1 -0
  3. package/dist/runtimes/claude/bridge-cache.js +257 -0
  4. package/dist/runtimes/claude/bridge-cache.js.map +1 -0
  5. package/dist/runtimes/claude/claude-runtime.d.ts +30 -0
  6. package/dist/runtimes/claude/claude-runtime.d.ts.map +1 -0
  7. package/dist/runtimes/claude/claude-runtime.js +88 -0
  8. package/dist/runtimes/claude/claude-runtime.js.map +1 -0
  9. package/dist/runtimes/claude/claude-session.d.ts +154 -0
  10. package/dist/runtimes/claude/claude-session.d.ts.map +1 -0
  11. package/dist/runtimes/claude/claude-session.js +851 -0
  12. package/dist/runtimes/claude/claude-session.js.map +1 -0
  13. package/dist/runtimes/claude/config-mapper.d.ts +69 -0
  14. package/dist/runtimes/claude/config-mapper.d.ts.map +1 -0
  15. package/dist/runtimes/claude/config-mapper.js +458 -0
  16. package/dist/runtimes/claude/config-mapper.js.map +1 -0
  17. package/dist/runtimes/claude/epoch-store.d.ts +28 -0
  18. package/dist/runtimes/claude/epoch-store.d.ts.map +1 -0
  19. package/dist/runtimes/claude/epoch-store.js +199 -0
  20. package/dist/runtimes/claude/epoch-store.js.map +1 -0
  21. package/dist/runtimes/claude/fallback-runtime.d.ts +14 -0
  22. package/dist/runtimes/claude/fallback-runtime.d.ts.map +1 -0
  23. package/dist/runtimes/claude/fallback-runtime.js +64 -0
  24. package/dist/runtimes/claude/fallback-runtime.js.map +1 -0
  25. package/dist/runtimes/claude/index.d.ts +24 -0
  26. package/dist/runtimes/claude/index.d.ts.map +1 -0
  27. package/dist/runtimes/claude/index.js +61 -0
  28. package/dist/runtimes/claude/index.js.map +1 -0
  29. package/dist/runtimes/claude/message-mapper.d.ts +135 -0
  30. package/dist/runtimes/claude/message-mapper.d.ts.map +1 -0
  31. package/dist/runtimes/claude/message-mapper.js +504 -0
  32. package/dist/runtimes/claude/message-mapper.js.map +1 -0
  33. package/dist/runtimes/claude/one-shot.d.ts +48 -0
  34. package/dist/runtimes/claude/one-shot.d.ts.map +1 -0
  35. package/dist/runtimes/claude/one-shot.js +186 -0
  36. package/dist/runtimes/claude/one-shot.js.map +1 -0
  37. package/dist/runtimes/claude/protocol-bridge.d.ts +138 -0
  38. package/dist/runtimes/claude/protocol-bridge.d.ts.map +1 -0
  39. package/dist/runtimes/claude/protocol-bridge.js +1170 -0
  40. package/dist/runtimes/claude/protocol-bridge.js.map +1 -0
  41. package/dist/runtimes/claude/protocol-router.d.ts +113 -0
  42. package/dist/runtimes/claude/protocol-router.d.ts.map +1 -0
  43. package/dist/runtimes/claude/protocol-router.js +514 -0
  44. package/dist/runtimes/claude/protocol-router.js.map +1 -0
  45. package/dist/runtimes/claude/session-epoch.d.ts +156 -0
  46. package/dist/runtimes/claude/session-epoch.d.ts.map +1 -0
  47. package/dist/runtimes/claude/session-epoch.js +365 -0
  48. package/dist/runtimes/claude/session-epoch.js.map +1 -0
  49. package/dist/runtimes/claude/session-recovery.d.ts +78 -0
  50. package/dist/runtimes/claude/session-recovery.d.ts.map +1 -0
  51. package/dist/runtimes/claude/session-recovery.js +259 -0
  52. package/dist/runtimes/claude/session-recovery.js.map +1 -0
  53. package/dist/runtimes/claude/sse.d.ts +16 -0
  54. package/dist/runtimes/claude/sse.d.ts.map +1 -0
  55. package/dist/runtimes/claude/sse.js +46 -0
  56. package/dist/runtimes/claude/sse.js.map +1 -0
  57. package/dist/runtimes/claude/translate-chat.d.ts +106 -0
  58. package/dist/runtimes/claude/translate-chat.d.ts.map +1 -0
  59. package/dist/runtimes/claude/translate-chat.js +857 -0
  60. package/dist/runtimes/claude/translate-chat.js.map +1 -0
  61. package/dist/runtimes/claude/translate-responses.d.ts +76 -0
  62. package/dist/runtimes/claude/translate-responses.d.ts.map +1 -0
  63. package/dist/runtimes/claude/translate-responses.js +625 -0
  64. package/dist/runtimes/claude/translate-responses.js.map +1 -0
  65. package/dist/runtimes/claude/usage-ledger.d.ts +108 -0
  66. package/dist/runtimes/claude/usage-ledger.d.ts.map +1 -0
  67. package/dist/runtimes/claude/usage-ledger.js +189 -0
  68. package/dist/runtimes/claude/usage-ledger.js.map +1 -0
  69. package/dist/runtimes/copilot/sdk-backend.d.ts.map +1 -1
  70. package/dist/runtimes/copilot/sdk-backend.js +15 -104
  71. package/dist/runtimes/copilot/sdk-backend.js.map +1 -1
  72. package/dist/runtimes/mcp-env.d.ts +24 -0
  73. package/dist/runtimes/mcp-env.d.ts.map +1 -0
  74. package/dist/runtimes/mcp-env.js +81 -0
  75. package/dist/runtimes/mcp-env.js.map +1 -0
  76. package/dist/src/session-recovery-policy.d.ts +19 -0
  77. package/dist/src/session-recovery-policy.d.ts.map +1 -0
  78. package/dist/src/session-recovery-policy.js +43 -0
  79. package/dist/src/session-recovery-policy.js.map +1 -0
  80. package/dist/src/types.d.ts +45 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/types.js.map +1 -1
  83. 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