@agentxjs/claude-driver 1.9.1-dev

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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@agentxjs/claude-driver",
3
+ "version": "1.9.1-dev",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit",
10
+ "test": "bun test"
11
+ },
12
+ "dependencies": {
13
+ "@agentxjs/core": "workspace:*",
14
+ "@anthropic-ai/claude-agent-sdk": "^0.2.29",
15
+ "rxjs": "^7.8.2"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.3.3"
19
+ }
20
+ }
@@ -0,0 +1,598 @@
1
+ /**
2
+ * ClaudeDriver - Claude SDK Driver Implementation
3
+ *
4
+ * Implements the new Driver interface with clear input/output boundaries:
5
+ * - receive(message) returns AsyncIterable<DriverStreamEvent>
6
+ * - No EventBus dependency
7
+ * - Single session communication
8
+ *
9
+ * ```
10
+ * UserMessage
11
+ * │
12
+ * ▼
13
+ * ┌─────────────────┐
14
+ * │ ClaudeDriver │
15
+ * │ │
16
+ * │ receive() │──► AsyncIterable<DriverStreamEvent>
17
+ * │ │ │
18
+ * │ ▼ │
19
+ * │ SDK Query │
20
+ * └─────────────────┘
21
+ * │
22
+ * ▼
23
+ * Claude SDK
24
+ * ```
25
+ */
26
+
27
+ import type {
28
+ Driver,
29
+ DriverConfig,
30
+ DriverState,
31
+ DriverStreamEvent,
32
+ StopReason,
33
+ } from "@agentxjs/core/driver";
34
+ import type { UserMessage } from "@agentxjs/core/agent";
35
+ import type { SDKMessage, SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
36
+ import { Subject } from "rxjs";
37
+ import { createLogger } from "commonxjs/logger";
38
+ import { buildSDKUserMessage } from "./helpers";
39
+ import { SDKQueryLifecycle } from "./SDKQueryLifecycle";
40
+
41
+ const logger = createLogger("claude-driver/ClaudeDriver");
42
+
43
+ /**
44
+ * ClaudeDriver - Driver implementation for Claude SDK
45
+ *
46
+ * Implements the new Driver interface:
47
+ * - receive() returns AsyncIterable<DriverStreamEvent>
48
+ * - Clear input/output boundaries for recording/playback
49
+ * - Single session communication
50
+ */
51
+ export class ClaudeDriver implements Driver {
52
+ readonly name = "ClaudeDriver";
53
+
54
+ private _sessionId: string | null = null;
55
+ private _state: DriverState = "idle";
56
+
57
+ private readonly config: DriverConfig;
58
+ private queryLifecycle: SDKQueryLifecycle | null = null;
59
+
60
+ // For interrupt handling
61
+ private currentTurnSubject: Subject<DriverStreamEvent> | null = null;
62
+
63
+ constructor(config: DriverConfig) {
64
+ this.config = config;
65
+ }
66
+
67
+ // ============================================================================
68
+ // Driver Interface Properties
69
+ // ============================================================================
70
+
71
+ get sessionId(): string | null {
72
+ return this._sessionId;
73
+ }
74
+
75
+ get state(): DriverState {
76
+ return this._state;
77
+ }
78
+
79
+ // ============================================================================
80
+ // Lifecycle Methods
81
+ // ============================================================================
82
+
83
+ /**
84
+ * Initialize the Driver
85
+ *
86
+ * Starts SDK subprocess and MCP servers.
87
+ * Must be called before receive().
88
+ */
89
+ async initialize(): Promise<void> {
90
+ if (this._state !== "idle") {
91
+ throw new Error(`Cannot initialize: Driver is in "${this._state}" state`);
92
+ }
93
+
94
+ logger.info("Initializing ClaudeDriver", { agentId: this.config.agentId });
95
+
96
+ // SDKQueryLifecycle will be created lazily on first receive()
97
+ // This allows configuration to be validated early without starting subprocess
98
+
99
+ logger.info("ClaudeDriver initialized");
100
+ }
101
+
102
+ /**
103
+ * Dispose and cleanup resources
104
+ *
105
+ * Stops SDK subprocess and MCP servers.
106
+ * Driver cannot be used after dispose().
107
+ */
108
+ async dispose(): Promise<void> {
109
+ if (this._state === "disposed") {
110
+ return;
111
+ }
112
+
113
+ logger.info("Disposing ClaudeDriver", { agentId: this.config.agentId });
114
+
115
+ // Complete any pending turn
116
+ if (this.currentTurnSubject) {
117
+ this.currentTurnSubject.complete();
118
+ this.currentTurnSubject = null;
119
+ }
120
+
121
+ // Dispose SDK lifecycle
122
+ if (this.queryLifecycle) {
123
+ this.queryLifecycle.dispose();
124
+ this.queryLifecycle = null;
125
+ }
126
+
127
+ this._state = "disposed";
128
+ logger.info("ClaudeDriver disposed");
129
+ }
130
+
131
+ // ============================================================================
132
+ // Core Methods
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Receive a user message and return stream of events
137
+ *
138
+ * This is the main method for communication.
139
+ * Returns an AsyncIterable that yields DriverStreamEvent.
140
+ *
141
+ * @param message - User message to send
142
+ * @returns AsyncIterable of stream events
143
+ */
144
+ async *receive(message: UserMessage): AsyncIterable<DriverStreamEvent> {
145
+ if (this._state === "disposed") {
146
+ throw new Error("Cannot receive: Driver is disposed");
147
+ }
148
+
149
+ if (this._state === "active") {
150
+ throw new Error("Cannot receive: Driver is already processing a message");
151
+ }
152
+
153
+ this._state = "active";
154
+
155
+ try {
156
+ // Ensure SDK lifecycle is initialized
157
+ await this.ensureLifecycle();
158
+
159
+ // Create Subject for this turn's events
160
+ const turnSubject = new Subject<DriverStreamEvent>();
161
+ this.currentTurnSubject = turnSubject;
162
+
163
+ // Track completion
164
+ let isComplete = false;
165
+ let turnError: Error | null = null;
166
+
167
+ // Setup callbacks to convert SDK events to DriverStreamEvent
168
+ this.setupTurnCallbacks(turnSubject, () => {
169
+ isComplete = true;
170
+ }, (error) => {
171
+ turnError = error;
172
+ isComplete = true;
173
+ });
174
+
175
+ // Build and send SDK message
176
+ const sessionId = this._sessionId || "default";
177
+ const sdkMessage = buildSDKUserMessage(message, sessionId);
178
+
179
+ logger.debug("Sending message to Claude", {
180
+ content: typeof message.content === "string"
181
+ ? message.content.substring(0, 80)
182
+ : "[structured]",
183
+ agentId: this.config.agentId,
184
+ });
185
+
186
+ this.queryLifecycle!.send(sdkMessage);
187
+
188
+ // Yield events from Subject
189
+ yield* this.yieldFromSubject(turnSubject, () => isComplete, () => turnError);
190
+
191
+ } finally {
192
+ this._state = "idle";
193
+ this.currentTurnSubject = null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Interrupt current operation
199
+ *
200
+ * Stops the current receive() operation gracefully.
201
+ * The AsyncIterable will emit an "interrupted" event and complete.
202
+ */
203
+ interrupt(): void {
204
+ if (this._state !== "active") {
205
+ logger.debug("Interrupt called but no active operation");
206
+ return;
207
+ }
208
+
209
+ logger.debug("Interrupting ClaudeDriver");
210
+
211
+ // Emit interrupted event
212
+ if (this.currentTurnSubject) {
213
+ this.currentTurnSubject.next({
214
+ type: "interrupted",
215
+ timestamp: Date.now(),
216
+ data: { reason: "user" },
217
+ });
218
+ this.currentTurnSubject.complete();
219
+ }
220
+
221
+ // Interrupt SDK
222
+ if (this.queryLifecycle) {
223
+ this.queryLifecycle.interrupt();
224
+ }
225
+ }
226
+
227
+ // ============================================================================
228
+ // Private Methods
229
+ // ============================================================================
230
+
231
+ /**
232
+ * Ensure SDK lifecycle is initialized
233
+ */
234
+ private async ensureLifecycle(): Promise<void> {
235
+ if (this.queryLifecycle && this.queryLifecycle.initialized) {
236
+ return;
237
+ }
238
+
239
+ // Create new lifecycle
240
+ this.queryLifecycle = new SDKQueryLifecycle(
241
+ {
242
+ apiKey: this.config.apiKey,
243
+ baseUrl: this.config.baseUrl,
244
+ model: this.config.model,
245
+ systemPrompt: this.config.systemPrompt,
246
+ cwd: this.config.cwd,
247
+ resumeSessionId: this.config.resumeSessionId,
248
+ mcpServers: this.config.mcpServers,
249
+ },
250
+ {
251
+ onSessionIdCaptured: (sessionId) => {
252
+ this._sessionId = sessionId;
253
+ this.config.onSessionIdCaptured?.(sessionId);
254
+ },
255
+ }
256
+ );
257
+
258
+ await this.queryLifecycle.initialize();
259
+ }
260
+
261
+ /**
262
+ * Setup callbacks for a single turn
263
+ */
264
+ private setupTurnCallbacks(
265
+ subject: Subject<DriverStreamEvent>,
266
+ onComplete: () => void,
267
+ onError: (error: Error) => void
268
+ ): void {
269
+ if (!this.queryLifecycle) return;
270
+
271
+ // Context for tracking content block state
272
+ const blockContext = {
273
+ currentBlockType: null as "text" | "tool_use" | null,
274
+ currentToolId: null as string | null,
275
+ currentToolName: null as string | null,
276
+ lastStopReason: null as string | null,
277
+ accumulatedToolInput: "" as string,
278
+ };
279
+
280
+ // Update lifecycle callbacks for this turn
281
+ this.queryLifecycle.setCallbacks({
282
+ onStreamEvent: (msg: SDKMessage) => {
283
+ const event = this.convertStreamEvent(msg as SDKPartialAssistantMessage, blockContext);
284
+ if (event) {
285
+ subject.next(event);
286
+ }
287
+ },
288
+
289
+ onUserMessage: (msg: SDKMessage) => {
290
+ const events = this.convertUserMessage(msg);
291
+ for (const event of events) {
292
+ subject.next(event);
293
+ }
294
+ },
295
+
296
+ onResult: (msg: SDKMessage) => {
297
+ const resultMsg = msg as { is_error?: boolean; error?: { message?: string } };
298
+
299
+ if (resultMsg.is_error) {
300
+ subject.next({
301
+ type: "error",
302
+ timestamp: Date.now(),
303
+ data: {
304
+ message: resultMsg.error?.message || "Unknown error",
305
+ errorCode: "sdk_error",
306
+ },
307
+ });
308
+ }
309
+
310
+ subject.complete();
311
+ onComplete();
312
+ },
313
+
314
+ onError: (error: Error) => {
315
+ subject.next({
316
+ type: "error",
317
+ timestamp: Date.now(),
318
+ data: {
319
+ message: error.message,
320
+ errorCode: "runtime_error",
321
+ },
322
+ });
323
+ subject.complete();
324
+ onError(error);
325
+ },
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Convert SDK stream_event to DriverStreamEvent
331
+ */
332
+ private convertStreamEvent(
333
+ sdkMsg: SDKPartialAssistantMessage,
334
+ blockContext: {
335
+ currentBlockType: "text" | "tool_use" | null;
336
+ currentToolId: string | null;
337
+ currentToolName: string | null;
338
+ lastStopReason: string | null;
339
+ accumulatedToolInput: string;
340
+ }
341
+ ): DriverStreamEvent | null {
342
+ const event = sdkMsg.event;
343
+ const timestamp = Date.now();
344
+
345
+ switch (event.type) {
346
+ case "message_start":
347
+ return {
348
+ type: "message_start",
349
+ timestamp,
350
+ data: {
351
+ messageId: event.message.id,
352
+ model: event.message.model,
353
+ },
354
+ };
355
+
356
+ case "content_block_start": {
357
+ const contentBlock = event.content_block as { type: string; id?: string; name?: string };
358
+
359
+ if (contentBlock.type === "text") {
360
+ blockContext.currentBlockType = "text";
361
+ // text_content_block_start is internal, don't emit
362
+ return null;
363
+ } else if (contentBlock.type === "tool_use") {
364
+ blockContext.currentBlockType = "tool_use";
365
+ blockContext.currentToolId = contentBlock.id || null;
366
+ blockContext.currentToolName = contentBlock.name || null;
367
+ blockContext.accumulatedToolInput = "";
368
+ return {
369
+ type: "tool_use_start",
370
+ timestamp,
371
+ data: {
372
+ toolCallId: contentBlock.id || "",
373
+ toolName: contentBlock.name || "",
374
+ },
375
+ };
376
+ }
377
+ return null;
378
+ }
379
+
380
+ case "content_block_delta": {
381
+ const delta = event.delta as { type: string; text?: string; partial_json?: string };
382
+
383
+ if (delta.type === "text_delta") {
384
+ return {
385
+ type: "text_delta",
386
+ timestamp,
387
+ data: { text: delta.text || "" },
388
+ };
389
+ } else if (delta.type === "input_json_delta") {
390
+ blockContext.accumulatedToolInput += delta.partial_json || "";
391
+ return {
392
+ type: "input_json_delta",
393
+ timestamp,
394
+ data: { partialJson: delta.partial_json || "" },
395
+ };
396
+ }
397
+ return null;
398
+ }
399
+
400
+ case "content_block_stop":
401
+ if (blockContext.currentBlockType === "tool_use" && blockContext.currentToolId) {
402
+ // Parse accumulated JSON
403
+ let input: Record<string, unknown> = {};
404
+ try {
405
+ if (blockContext.accumulatedToolInput) {
406
+ input = JSON.parse(blockContext.accumulatedToolInput);
407
+ }
408
+ } catch {
409
+ logger.warn("Failed to parse tool input JSON", {
410
+ input: blockContext.accumulatedToolInput,
411
+ });
412
+ }
413
+
414
+ const event: DriverStreamEvent = {
415
+ type: "tool_use_stop",
416
+ timestamp,
417
+ data: {
418
+ toolCallId: blockContext.currentToolId,
419
+ toolName: blockContext.currentToolName || "",
420
+ input,
421
+ },
422
+ };
423
+
424
+ // Reset block context
425
+ blockContext.currentBlockType = null;
426
+ blockContext.currentToolId = null;
427
+ blockContext.currentToolName = null;
428
+ blockContext.accumulatedToolInput = "";
429
+
430
+ return event;
431
+ }
432
+ // Reset for text blocks too
433
+ blockContext.currentBlockType = null;
434
+ return null;
435
+
436
+ case "message_delta": {
437
+ const msgDelta = event.delta as { stop_reason?: string };
438
+ if (msgDelta.stop_reason) {
439
+ blockContext.lastStopReason = msgDelta.stop_reason;
440
+ }
441
+ return null;
442
+ }
443
+
444
+ case "message_stop":
445
+ return {
446
+ type: "message_stop",
447
+ timestamp,
448
+ data: {
449
+ stopReason: this.mapStopReason(blockContext.lastStopReason),
450
+ },
451
+ };
452
+
453
+ default:
454
+ return null;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Convert SDK user message (contains tool_result)
460
+ */
461
+ private convertUserMessage(msg: SDKMessage): DriverStreamEvent[] {
462
+ const events: DriverStreamEvent[] = [];
463
+ const sdkMsg = msg as { message?: { content?: unknown[] } };
464
+
465
+ if (!sdkMsg.message || !Array.isArray(sdkMsg.message.content)) {
466
+ return events;
467
+ }
468
+
469
+ for (const block of sdkMsg.message.content) {
470
+ if (block && typeof block === "object" && "type" in block && block.type === "tool_result") {
471
+ const toolResultBlock = block as unknown as {
472
+ tool_use_id: string;
473
+ content: unknown;
474
+ is_error?: boolean;
475
+ };
476
+
477
+ events.push({
478
+ type: "tool_result",
479
+ timestamp: Date.now(),
480
+ data: {
481
+ toolCallId: toolResultBlock.tool_use_id,
482
+ result: toolResultBlock.content,
483
+ isError: toolResultBlock.is_error,
484
+ },
485
+ });
486
+ }
487
+ }
488
+
489
+ return events;
490
+ }
491
+
492
+ /**
493
+ * Map SDK stop reason to our StopReason type
494
+ */
495
+ private mapStopReason(sdkReason: string | null): StopReason {
496
+ switch (sdkReason) {
497
+ case "end_turn":
498
+ return "end_turn";
499
+ case "max_tokens":
500
+ return "max_tokens";
501
+ case "tool_use":
502
+ return "tool_use";
503
+ case "stop_sequence":
504
+ return "stop_sequence";
505
+ default:
506
+ return "other";
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Yield events from Subject as AsyncIterable
512
+ */
513
+ private async *yieldFromSubject(
514
+ subject: Subject<DriverStreamEvent>,
515
+ _isComplete: () => boolean,
516
+ getError: () => Error | null
517
+ ): AsyncIterable<DriverStreamEvent> {
518
+ const queue: DriverStreamEvent[] = [];
519
+ let resolve: ((value: IteratorResult<DriverStreamEvent>) => void) | null = null;
520
+ let done = false;
521
+
522
+ const subscription = subject.subscribe({
523
+ next: (value) => {
524
+ if (resolve) {
525
+ resolve({ value, done: false });
526
+ resolve = null;
527
+ } else {
528
+ queue.push(value);
529
+ }
530
+ },
531
+ complete: () => {
532
+ done = true;
533
+ if (resolve) {
534
+ resolve({ value: undefined as unknown as DriverStreamEvent, done: true });
535
+ resolve = null;
536
+ }
537
+ },
538
+ error: (_err) => {
539
+ done = true;
540
+ // Error is handled via getError()
541
+ },
542
+ });
543
+
544
+ try {
545
+ while (!done || queue.length > 0) {
546
+ const error = getError();
547
+ if (error) {
548
+ throw error;
549
+ }
550
+
551
+ if (queue.length > 0) {
552
+ yield queue.shift()!;
553
+ } else if (!done) {
554
+ const result = await new Promise<IteratorResult<DriverStreamEvent>>((res) => {
555
+ resolve = res;
556
+ });
557
+
558
+ if (!result.done) {
559
+ yield result.value;
560
+ }
561
+ }
562
+ }
563
+ } finally {
564
+ subscription.unsubscribe();
565
+ }
566
+ }
567
+ }
568
+
569
+ /**
570
+ * CreateDriver function for ClaudeDriver
571
+ *
572
+ * Factory function that creates a ClaudeDriver instance.
573
+ * Conforms to the CreateDriver type from @agentxjs/core/driver.
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * import { createClaudeDriver } from "@agentxjs/claude-driver";
578
+ *
579
+ * const driver = createClaudeDriver({
580
+ * apiKey: process.env.ANTHROPIC_API_KEY!,
581
+ * agentId: "my-agent",
582
+ * systemPrompt: "You are helpful",
583
+ * });
584
+ *
585
+ * await driver.initialize();
586
+ *
587
+ * for await (const event of driver.receive({ content: "Hello" })) {
588
+ * if (event.type === "text_delta") {
589
+ * process.stdout.write(event.data.text);
590
+ * }
591
+ * }
592
+ *
593
+ * await driver.dispose();
594
+ * ```
595
+ */
596
+ export function createClaudeDriver(config: DriverConfig): Driver {
597
+ return new ClaudeDriver(config);
598
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * SDKQueryLifecycle - Manages Claude SDK query lifecycle
3
+ *
4
+ * This class encapsulates the low-level SDK interaction:
5
+ * - Query initialization and lazy loading
6
+ * - Background listener for SDK responses
7
+ * - Interrupt and cleanup operations
8
+ *
9
+ * It emits events via callbacks, allowing the parent Driver
10
+ * to handle business logic like timeout management.
11
+ */
12
+
13
+ import {
14
+ query,
15
+ type SDKUserMessage,
16
+ type Query,
17
+ type SDKMessage,
18
+ type McpServerConfig,
19
+ } from "@anthropic-ai/claude-agent-sdk";
20
+ import { Subject } from "rxjs";
21
+ import { createLogger } from "commonxjs/logger";
22
+ import { buildOptions, type EnvironmentContext } from "./buildOptions";
23
+ import { observableToAsyncIterable } from "./observableToAsyncIterable";
24
+
25
+ const logger = createLogger("claude-driver/SDKQueryLifecycle");
26
+
27
+ /**
28
+ * Callbacks for SDK events
29
+ */
30
+ export interface SDKQueryCallbacks {
31
+ /** Called when a stream_event is received */
32
+ onStreamEvent?: (msg: SDKMessage) => void;
33
+ /** Called when a user message is received (contains tool_result) */
34
+ onUserMessage?: (msg: SDKMessage) => void;
35
+ /** Called when a result is received */
36
+ onResult?: (msg: SDKMessage) => void;
37
+ /** Called when session ID is captured */
38
+ onSessionIdCaptured?: (sessionId: string) => void;
39
+ /** Called when an error occurs */
40
+ onError?: (error: Error) => void;
41
+ /** Called when the listener exits (normally or due to error) */
42
+ onListenerExit?: (reason: "normal" | "abort" | "error") => void;
43
+ }
44
+
45
+ /**
46
+ * Configuration for SDKQueryLifecycle
47
+ */
48
+ export interface SDKQueryConfig {
49
+ apiKey: string;
50
+ baseUrl?: string;
51
+ model?: string;
52
+ systemPrompt?: string;
53
+ cwd?: string;
54
+ resumeSessionId?: string;
55
+ mcpServers?: Record<string, McpServerConfig>;
56
+ claudeCodePath?: string;
57
+ }
58
+
59
+ /**
60
+ * SDKQueryLifecycle - Manages the lifecycle of a Claude SDK query
61
+ *
62
+ * Responsibilities:
63
+ * - Lazy initialization of SDK query
64
+ * - Background listener for SDK responses
65
+ * - Interrupt and cleanup operations
66
+ * - Resource management (subprocess termination)
67
+ */
68
+ export class SDKQueryLifecycle {
69
+ private readonly config: SDKQueryConfig;
70
+ private _callbacks: SDKQueryCallbacks;
71
+
72
+ private promptSubject = new Subject<SDKUserMessage>();
73
+ private claudeQuery: Query | null = null;
74
+ private isInitialized = false;
75
+ private abortController: AbortController | null = null;
76
+ private capturedSessionId: string | null = null;
77
+
78
+ constructor(config: SDKQueryConfig, callbacks: SDKQueryCallbacks = {}) {
79
+ this.config = config;
80
+ this._callbacks = callbacks;
81
+ }
82
+
83
+ /**
84
+ * Get current callbacks (for reading/modification)
85
+ */
86
+ get callbacks(): SDKQueryCallbacks {
87
+ return this._callbacks;
88
+ }
89
+
90
+ /**
91
+ * Update callbacks
92
+ *
93
+ * Allows changing callbacks after initialization.
94
+ * Useful for per-turn callback setup.
95
+ */
96
+ setCallbacks(callbacks: Partial<SDKQueryCallbacks>): void {
97
+ this._callbacks = { ...this._callbacks, ...callbacks };
98
+ }
99
+
100
+ /**
101
+ * Check if the query is initialized
102
+ */
103
+ get initialized(): boolean {
104
+ return this.isInitialized;
105
+ }
106
+
107
+ /**
108
+ * Warmup the SDK query (pre-initialize)
109
+ *
110
+ * Call this early to start the SDK subprocess before the first message.
111
+ * This reduces latency for the first user message.
112
+ *
113
+ * @returns Promise that resolves when SDK is ready
114
+ */
115
+ async warmup(): Promise<void> {
116
+ logger.info("Warming up SDKQueryLifecycle");
117
+ await this.initialize();
118
+ logger.info("SDKQueryLifecycle warmup complete");
119
+ }
120
+
121
+ /**
122
+ * Initialize the SDK query (lazy initialization)
123
+ *
124
+ * Creates the query and starts the background listener.
125
+ * Safe to call multiple times - will only initialize once.
126
+ */
127
+ async initialize(): Promise<void> {
128
+ if (this.isInitialized) return;
129
+
130
+ logger.info("Initializing SDKQueryLifecycle");
131
+
132
+ this.abortController = new AbortController();
133
+
134
+ const context: EnvironmentContext = {
135
+ apiKey: this.config.apiKey,
136
+ baseUrl: this.config.baseUrl,
137
+ model: this.config.model,
138
+ systemPrompt: this.config.systemPrompt,
139
+ cwd: this.config.cwd,
140
+ resume: this.config.resumeSessionId,
141
+ mcpServers: this.config.mcpServers,
142
+ claudeCodePath: this.config.claudeCodePath,
143
+ };
144
+
145
+ const sdkOptions = buildOptions(context, this.abortController);
146
+ const promptStream = observableToAsyncIterable<SDKUserMessage>(this.promptSubject);
147
+
148
+ this.claudeQuery = query({
149
+ prompt: promptStream,
150
+ options: sdkOptions,
151
+ });
152
+
153
+ this.isInitialized = true;
154
+
155
+ // Start background listener
156
+ this.startBackgroundListener();
157
+
158
+ logger.info("SDKQueryLifecycle initialized");
159
+ }
160
+
161
+ /**
162
+ * Send a message to the SDK
163
+ *
164
+ * Must call initialize() first.
165
+ */
166
+ send(message: SDKUserMessage): void {
167
+ if (!this.isInitialized) {
168
+ throw new Error("SDKQueryLifecycle not initialized. Call initialize() first.");
169
+ }
170
+ this.promptSubject.next(message);
171
+ }
172
+
173
+ /**
174
+ * Interrupt the current SDK operation
175
+ */
176
+ interrupt(): void {
177
+ if (this.claudeQuery) {
178
+ logger.debug("Interrupting SDK query");
179
+ this.claudeQuery.interrupt().catch((err) => {
180
+ logger.debug("SDK interrupt() error (may be expected)", { error: err });
181
+ });
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Reset state and cleanup resources
187
+ *
188
+ * This properly terminates the Claude subprocess by:
189
+ * 1. Completing the prompt stream (signals end of input)
190
+ * 2. Interrupting any ongoing operation
191
+ * 3. Resetting state for potential reuse
192
+ */
193
+ reset(): void {
194
+ logger.debug("Resetting SDKQueryLifecycle");
195
+
196
+ // 1. Complete the prompt stream first (signals end of input to subprocess)
197
+ this.promptSubject.complete();
198
+
199
+ // 2. Interrupt any ongoing operation
200
+ if (this.claudeQuery) {
201
+ this.claudeQuery.interrupt().catch((err) => {
202
+ logger.debug("SDK interrupt() during reset (may be expected)", { error: err });
203
+ });
204
+ }
205
+
206
+ // 3. Reset state for potential reuse
207
+ this.isInitialized = false;
208
+ this.claudeQuery = null;
209
+ this.abortController = null;
210
+ this.promptSubject = new Subject<SDKUserMessage>();
211
+ }
212
+
213
+ /**
214
+ * Dispose and cleanup all resources
215
+ *
216
+ * Should be called when the lifecycle is no longer needed.
217
+ */
218
+ dispose(): void {
219
+ logger.debug("Disposing SDKQueryLifecycle");
220
+
221
+ // Interrupt first
222
+ if (this.claudeQuery) {
223
+ this.claudeQuery.interrupt().catch((err) => {
224
+ logger.debug("SDK interrupt() during dispose (may be expected)", { error: err });
225
+ });
226
+ }
227
+
228
+ // Abort controller
229
+ if (this.abortController) {
230
+ this.abortController.abort();
231
+ }
232
+
233
+ // Reset state
234
+ this.reset();
235
+
236
+ logger.debug("SDKQueryLifecycle disposed");
237
+ }
238
+
239
+ /**
240
+ * Start the background listener for SDK responses
241
+ */
242
+ private startBackgroundListener(): void {
243
+ (async () => {
244
+ try {
245
+ for await (const sdkMsg of this.claudeQuery!) {
246
+ logger.debug("SDK message received", {
247
+ type: sdkMsg.type,
248
+ subtype: (sdkMsg as { subtype?: string }).subtype,
249
+ sessionId: sdkMsg.session_id,
250
+ });
251
+
252
+ // Capture session ID (only once, on first occurrence)
253
+ if (
254
+ sdkMsg.session_id &&
255
+ this._callbacks.onSessionIdCaptured &&
256
+ this.capturedSessionId !== sdkMsg.session_id
257
+ ) {
258
+ this.capturedSessionId = sdkMsg.session_id;
259
+ this._callbacks.onSessionIdCaptured(sdkMsg.session_id);
260
+ }
261
+
262
+ // Forward stream_event
263
+ if (sdkMsg.type === "stream_event") {
264
+ this._callbacks.onStreamEvent?.(sdkMsg);
265
+ }
266
+
267
+ // Forward user message (contains tool_result)
268
+ if (sdkMsg.type === "user") {
269
+ this._callbacks.onUserMessage?.(sdkMsg);
270
+ }
271
+
272
+ // Handle result
273
+ if (sdkMsg.type === "result") {
274
+ logger.info("SDK result received", {
275
+ subtype: (sdkMsg as { subtype?: string }).subtype,
276
+ is_error: (sdkMsg as { is_error?: boolean }).is_error,
277
+ });
278
+ this._callbacks.onResult?.(sdkMsg);
279
+ }
280
+ }
281
+
282
+ // Normal exit
283
+ this._callbacks.onListenerExit?.("normal");
284
+ } catch (error) {
285
+ if (this.isAbortError(error)) {
286
+ logger.debug("Background listener aborted (expected during interrupt)");
287
+ this._callbacks.onListenerExit?.("abort");
288
+ } else {
289
+ logger.error("Background listener error", { error });
290
+ this._callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
291
+ this._callbacks.onListenerExit?.("error");
292
+ }
293
+
294
+ // Always reset state on any error
295
+ this.reset();
296
+ }
297
+ })();
298
+ }
299
+
300
+ /**
301
+ * Check if an error is an abort error
302
+ */
303
+ private isAbortError(error: unknown): boolean {
304
+ if (error instanceof Error) {
305
+ if (error.name === "AbortError") return true;
306
+ if (error.message.includes("aborted")) return true;
307
+ if (error.message.includes("abort")) return true;
308
+ }
309
+ return false;
310
+ }
311
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Build Claude SDK Options from Driver Config
3
+ *
4
+ * Converts driver configuration to Claude SDK Options format.
5
+ */
6
+
7
+ import type { Options, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
8
+ import { createLogger } from "commonxjs/logger";
9
+
10
+ const logger = createLogger("claude-driver/buildOptions");
11
+
12
+ /**
13
+ * Environment context for Claude SDK
14
+ */
15
+ export interface EnvironmentContext {
16
+ apiKey: string;
17
+ baseUrl?: string;
18
+ model?: string;
19
+ systemPrompt?: string;
20
+ cwd?: string;
21
+ permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
22
+ resume?: string;
23
+ maxTurns?: number;
24
+ maxThinkingTokens?: number;
25
+ mcpServers?: Record<string, McpServerConfig>;
26
+ claudeCodePath?: string;
27
+ }
28
+
29
+ /**
30
+ * Build Claude SDK options from environment context
31
+ */
32
+ export function buildOptions(
33
+ context: EnvironmentContext,
34
+ abortController: AbortController
35
+ ): Options {
36
+ const options: Options = {
37
+ abortController,
38
+ includePartialMessages: true,
39
+ };
40
+
41
+ // Working directory
42
+ if (context.cwd) {
43
+ options.cwd = context.cwd;
44
+ }
45
+
46
+ // Environment variables - must include PATH for subprocess to find node
47
+ const env: Record<string, string> = {};
48
+ // Copy all process.env values, filtering out undefined
49
+ for (const [key, value] of Object.entries(process.env)) {
50
+ if (value !== undefined) {
51
+ env[key] = value;
52
+ }
53
+ }
54
+ // Ensure PATH is set (critical for subprocess to find node)
55
+ if (!env.PATH && process.env.PATH) {
56
+ env.PATH = process.env.PATH;
57
+ }
58
+
59
+ // Mark process as AgentX environment for identification and debugging
60
+ env.AGENTX_ENVIRONMENT = "true";
61
+
62
+ if (context.baseUrl) {
63
+ env.ANTHROPIC_BASE_URL = context.baseUrl;
64
+ }
65
+ if (context.apiKey) {
66
+ env.ANTHROPIC_API_KEY = context.apiKey;
67
+ }
68
+ options.env = env;
69
+
70
+ logger.info("buildOptions called", {
71
+ hasPath: !!env.PATH,
72
+ pathLength: env.PATH?.length,
73
+ hasApiKey: !!env.ANTHROPIC_API_KEY,
74
+ hasBaseUrl: !!env.ANTHROPIC_BASE_URL,
75
+ baseUrl: env.ANTHROPIC_BASE_URL,
76
+ model: context.model,
77
+ permissionMode: context.permissionMode || "bypassPermissions",
78
+ cwd: context.cwd,
79
+ systemPrompt: context.systemPrompt,
80
+ mcpServers: context.mcpServers ? Object.keys(context.mcpServers) : [],
81
+ });
82
+
83
+ // Capture stderr from SDK subprocess for debugging
84
+ options.stderr = (data: string) => {
85
+ logger.info("SDK stderr", { data: data.trim() });
86
+ };
87
+
88
+ // Set Claude Code executable path if provided
89
+ if (context.claudeCodePath) {
90
+ options.pathToClaudeCodeExecutable = context.claudeCodePath;
91
+ logger.info("Claude Code path configured", { path: context.claudeCodePath });
92
+ }
93
+
94
+ // Model configuration
95
+ if (context.model) options.model = context.model;
96
+ if (context.systemPrompt) options.systemPrompt = context.systemPrompt;
97
+ if (context.maxTurns) options.maxTurns = context.maxTurns;
98
+ if (context.maxThinkingTokens) options.maxThinkingTokens = context.maxThinkingTokens;
99
+
100
+ // Session control
101
+ if (context.resume) options.resume = context.resume;
102
+
103
+ // MCP servers
104
+ if (context.mcpServers) {
105
+ options.mcpServers = context.mcpServers;
106
+ logger.info("MCP servers configured", {
107
+ serverNames: Object.keys(context.mcpServers),
108
+ });
109
+ }
110
+
111
+ // Permission system
112
+ if (context.permissionMode) {
113
+ options.permissionMode = context.permissionMode;
114
+ // Required when using bypassPermissions mode
115
+ if (context.permissionMode === "bypassPermissions") {
116
+ options.allowDangerouslySkipPermissions = true;
117
+ }
118
+ } else {
119
+ // Default to bypass permissions (agent runs autonomously)
120
+ options.permissionMode = "bypassPermissions";
121
+ options.allowDangerouslySkipPermissions = true;
122
+ }
123
+
124
+ logger.info("SDK Options built", {
125
+ model: options.model,
126
+ systemPrompt: options.systemPrompt,
127
+ permissionMode: options.permissionMode,
128
+ cwd: options.cwd,
129
+ resume: options.resume,
130
+ maxTurns: options.maxTurns,
131
+ mcpServers: options.mcpServers ? Object.keys(options.mcpServers) : [],
132
+ });
133
+
134
+ return options;
135
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Helper functions for Claude Driver
3
+ */
4
+
5
+ import type { UserMessage, ContentPart, TextPart, ImagePart, FilePart } from "@agentxjs/core/agent";
6
+ import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
7
+
8
+ /**
9
+ * Claude API content block types
10
+ */
11
+ type ClaudeTextBlock = {
12
+ type: "text";
13
+ text: string;
14
+ };
15
+
16
+ type ClaudeImageBlock = {
17
+ type: "image";
18
+ source: {
19
+ type: "base64";
20
+ media_type: string;
21
+ data: string;
22
+ };
23
+ };
24
+
25
+ type ClaudeDocumentBlock = {
26
+ type: "document";
27
+ source: {
28
+ type: "base64";
29
+ media_type: string;
30
+ data: string;
31
+ };
32
+ };
33
+
34
+ type ClaudeContentBlock = ClaudeTextBlock | ClaudeImageBlock | ClaudeDocumentBlock;
35
+
36
+ /**
37
+ * Type guards for ContentPart discrimination
38
+ */
39
+ function isTextPart(part: ContentPart): part is TextPart {
40
+ return part.type === "text";
41
+ }
42
+
43
+ function isImagePart(part: ContentPart): part is ImagePart {
44
+ return part.type === "image";
45
+ }
46
+
47
+ function isFilePart(part: ContentPart): part is FilePart {
48
+ return part.type === "file";
49
+ }
50
+
51
+ /**
52
+ * Build SDK content from UserMessage
53
+ *
54
+ * Converts AgentX ContentPart[] to Claude API format:
55
+ * - Pure text messages return as string (for efficiency)
56
+ * - Mixed content returns as ClaudeContentBlock[]
57
+ */
58
+ export function buildSDKContent(message: UserMessage): string | ClaudeContentBlock[] {
59
+ // String content - return as-is
60
+ if (typeof message.content === "string") {
61
+ return message.content;
62
+ }
63
+
64
+ // Not an array - return empty string
65
+ if (!Array.isArray(message.content)) {
66
+ return "";
67
+ }
68
+
69
+ const parts = message.content as ContentPart[];
70
+
71
+ // Check if we have only text parts
72
+ const hasNonTextParts = parts.some((p) => !isTextPart(p));
73
+
74
+ if (!hasNonTextParts) {
75
+ // Pure text - return as string for efficiency
76
+ return parts
77
+ .filter(isTextPart)
78
+ .map((p) => p.text)
79
+ .join("\n");
80
+ }
81
+
82
+ // Mixed content - return as content blocks
83
+ return parts.map((part): ClaudeContentBlock => {
84
+ if (isTextPart(part)) {
85
+ return {
86
+ type: "text",
87
+ text: part.text,
88
+ };
89
+ }
90
+
91
+ if (isImagePart(part)) {
92
+ return {
93
+ type: "image",
94
+ source: {
95
+ type: "base64",
96
+ media_type: part.mediaType,
97
+ data: part.data,
98
+ },
99
+ };
100
+ }
101
+
102
+ if (isFilePart(part)) {
103
+ // PDF and other files use "document" type in Claude API
104
+ return {
105
+ type: "document",
106
+ source: {
107
+ type: "base64",
108
+ media_type: part.mediaType,
109
+ data: part.data,
110
+ },
111
+ };
112
+ }
113
+
114
+ // Unknown type - return empty text block
115
+ return { type: "text", text: "" };
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Build SDK UserMessage from AgentX UserMessage
121
+ */
122
+ export function buildSDKUserMessage(message: UserMessage, sessionId: string): SDKUserMessage {
123
+ return {
124
+ type: "user",
125
+ message: { role: "user", content: buildSDKContent(message) },
126
+ parent_tool_use_id: null,
127
+ session_id: sessionId,
128
+ };
129
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @agentxjs/claude-driver
3
+ *
4
+ * Claude SDK Driver for AgentX
5
+ *
6
+ * Provides Driver implementation for connecting AgentX to Claude SDK.
7
+ *
8
+ * Key Design:
9
+ * - Clear input/output boundary (for recording/playback)
10
+ * - receive() returns AsyncIterable<DriverStreamEvent>
11
+ * - Single session communication
12
+ *
13
+ * Usage:
14
+ * ```typescript
15
+ * import { createClaudeDriver } from "@agentxjs/claude-driver";
16
+ *
17
+ * const driver = createClaudeDriver({
18
+ * apiKey: process.env.ANTHROPIC_API_KEY!,
19
+ * agentId: "my-agent",
20
+ * systemPrompt: "You are helpful",
21
+ * });
22
+ *
23
+ * await driver.initialize();
24
+ *
25
+ * for await (const event of driver.receive({ content: "Hello" })) {
26
+ * if (event.type === "text_delta") {
27
+ * process.stdout.write(event.data.text);
28
+ * }
29
+ * }
30
+ *
31
+ * await driver.dispose();
32
+ * ```
33
+ */
34
+
35
+ // Main exports
36
+ export { ClaudeDriver, createClaudeDriver } from "./ClaudeDriver";
37
+
38
+ // Re-export types from core for convenience
39
+ export type {
40
+ Driver,
41
+ DriverConfig,
42
+ DriverState,
43
+ CreateDriver,
44
+ DriverStreamEvent,
45
+ StopReason,
46
+ } from "@agentxjs/core/driver";
47
+
48
+ // Internal utilities (for advanced usage)
49
+ export {
50
+ SDKQueryLifecycle,
51
+ type SDKQueryCallbacks,
52
+ type SDKQueryConfig,
53
+ } from "./SDKQueryLifecycle";
54
+ export { buildSDKContent, buildSDKUserMessage } from "./helpers";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Convert RxJS Observable to AsyncIterable
3
+ *
4
+ * Utility for converting Observable streams to AsyncIterable
5
+ * for use with SDKs that accept AsyncIterable input.
6
+ */
7
+
8
+ import type { Observable } from "rxjs";
9
+
10
+ export async function* observableToAsyncIterable<T>(observable: Observable<T>): AsyncIterable<T> {
11
+ const queue: T[] = [];
12
+ let resolve: ((value: IteratorResult<T>) => void) | null = null;
13
+ let reject: ((error: Error) => void) | null = null;
14
+ let done = false;
15
+ let error: Error | null = null;
16
+
17
+ const subscription = observable.subscribe({
18
+ next: (value) => {
19
+ if (resolve) {
20
+ resolve({ value, done: false });
21
+ resolve = null;
22
+ reject = null;
23
+ } else {
24
+ queue.push(value);
25
+ }
26
+ },
27
+ error: (err) => {
28
+ error = err instanceof Error ? err : new Error(String(err));
29
+ done = true;
30
+ if (reject) {
31
+ reject(error);
32
+ resolve = null;
33
+ reject = null;
34
+ }
35
+ },
36
+ complete: () => {
37
+ done = true;
38
+ if (resolve) {
39
+ resolve({ value: undefined as unknown as T, done: true });
40
+ resolve = null;
41
+ reject = null;
42
+ }
43
+ },
44
+ });
45
+
46
+ try {
47
+ while (!done || queue.length > 0) {
48
+ if (error) {
49
+ throw error;
50
+ }
51
+
52
+ if (queue.length > 0) {
53
+ yield queue.shift()!;
54
+ } else if (!done) {
55
+ const result = await new Promise<{ value: T; done: false } | { done: true }>((res, rej) => {
56
+ resolve = (iterResult) => {
57
+ if (iterResult.done) {
58
+ done = true;
59
+ res({ done: true });
60
+ } else {
61
+ res({ value: iterResult.value, done: false });
62
+ }
63
+ };
64
+ reject = rej;
65
+ });
66
+
67
+ if (!result.done) {
68
+ yield result.value;
69
+ }
70
+ }
71
+ }
72
+ } finally {
73
+ subscription.unsubscribe();
74
+ }
75
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["bun-types"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts", "src/**/*.spec.ts"]
10
+ }