coding-agent-wrapper 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,648 @@
1
+ """Claude Code provider — wraps the ``claude`` CLI in stream-json mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import uuid
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any
15
+
16
+ from caw.display import Display, get_global_display
17
+ from caw.models import (
18
+ ContentBlock,
19
+ InteractiveResult,
20
+ MCPServer,
21
+ ModelTier,
22
+ TextBlock,
23
+ ThinkingBlock,
24
+ ToolGroup,
25
+ ToolUse,
26
+ Trajectory,
27
+ Turn,
28
+ UsageStats,
29
+ )
30
+ from caw.provider import Provider, ProviderSession
31
+
32
+ # -- Tool group → Claude Code tool name mapping --------------------------------
33
+
34
+ _MODEL_TIER_MAP: dict[ModelTier, str] = {
35
+ ModelTier.STRONGEST: os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-6"),
36
+ ModelTier.FAST: os.environ.get("ANTHROPIC_SMALL_FAST_MODEL", "claude-haiku-4-5-20251001"),
37
+ }
38
+
39
+ _TOOL_GROUP_MAP: dict[ToolGroup, list[str]] = {
40
+ ToolGroup.READER: ["Read", "Glob", "Grep"],
41
+ ToolGroup.WRITER: ["Write", "Edit", "NotebookEdit"],
42
+ ToolGroup.EXEC: ["Bash"],
43
+ ToolGroup.WEB: ["WebFetch", "WebSearch"],
44
+ ToolGroup.PARALLEL: ["Task", "TaskOutput", "TaskStop"],
45
+ ToolGroup.INTERACTION: ["AskUserQuestion"],
46
+ }
47
+
48
+ # -- Subprocess registry + atexit cleanup -------------------------------------
49
+
50
+ _active_processes: set[subprocess.Popen] = set()
51
+ _process_lock = threading.Lock()
52
+
53
+
54
+ def _register_process(proc: subprocess.Popen) -> None:
55
+ with _process_lock:
56
+ _active_processes.add(proc)
57
+
58
+
59
+ def _unregister_process(proc: subprocess.Popen) -> None:
60
+ with _process_lock:
61
+ _active_processes.discard(proc)
62
+
63
+
64
+ def _cleanup_processes() -> None:
65
+ """Kill all tracked subprocesses at interpreter exit."""
66
+ with _process_lock:
67
+ procs = list(_active_processes)
68
+ for proc in procs:
69
+ try:
70
+ proc.kill()
71
+ except OSError:
72
+ pass
73
+
74
+
75
+ atexit.register(_cleanup_processes)
76
+
77
+ # -- Usage-limit detection ----------------------------------------------------
78
+
79
+ _LIMIT_RESET_RE = re.compile(
80
+ r"resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)",
81
+ re.IGNORECASE,
82
+ )
83
+ _USAGE_EXHAUSTED_RE = re.compile(r"\bout of (?:extra\s+)?usage\b", re.IGNORECASE)
84
+
85
+ _DEFAULT_WAIT_MINUTES = 60
86
+
87
+
88
+ def _parse_reset_minutes(text: str) -> int | None:
89
+ """Parse a Claude Code limit message and return minutes until reset (+ 5 min buffer).
90
+
91
+ Expected format: ``"resets 3am (UTC)"`` or ``"resets 3:30pm (US/Eastern)"``.
92
+ Returns ``None`` if the pattern is not found.
93
+ """
94
+ match = _LIMIT_RESET_RE.search(text)
95
+ if not match:
96
+ return None
97
+
98
+ hour = int(match.group(1))
99
+ minute = int(match.group(2) or 0)
100
+ ampm = match.group(3).lower()
101
+ tz_label = match.group(4).strip()
102
+
103
+ # Convert 12-hour to 24-hour
104
+ if ampm == "am" and hour == 12:
105
+ hour = 0
106
+ elif ampm == "pm" and hour != 12:
107
+ hour += 12
108
+
109
+ if tz_label.upper() == "UTC":
110
+ tz = timezone.utc
111
+ else:
112
+ try:
113
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
114
+
115
+ tz = ZoneInfo(tz_label)
116
+ except (ImportError, ZoneInfoNotFoundError):
117
+ return None
118
+
119
+ now = datetime.now(tz)
120
+ reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
121
+
122
+ if reset_time <= now:
123
+ reset_time += timedelta(days=1)
124
+
125
+ delta = reset_time - now
126
+ wait_minutes = int(delta.total_seconds() / 60) + 5 # 5-minute buffer
127
+ return max(1, wait_minutes)
128
+
129
+
130
+ def detect_usage_limit(text: str) -> int | None:
131
+ """Check whether *text* indicates a Claude usage limit.
132
+
133
+ Returns the number of minutes to wait before retrying, or ``None`` if no
134
+ limit was detected. When the reset time cannot be parsed from the message,
135
+ the caller-supplied default is used (see ``_DEFAULT_WAIT_MINUTES``).
136
+ """
137
+ lower = text.lower()
138
+ has_limit_phrase = "limit" in lower or _USAGE_EXHAUSTED_RE.search(text) is not None
139
+ if "resets" not in lower or not has_limit_phrase:
140
+ return None
141
+ return _parse_reset_minutes(text) or _DEFAULT_WAIT_MINUTES
142
+
143
+
144
+ class ClaudeCodeSession(ProviderSession):
145
+ """Live session backed by the ``claude`` CLI."""
146
+
147
+ def __init__(
148
+ self,
149
+ mcp_servers: list[MCPServer],
150
+ model: str | None = None,
151
+ system_prompt: str | None = None,
152
+ session_id: str | None = None,
153
+ disallowed_tools: list[str] | None = None,
154
+ reasoning: str | None = None,
155
+ ) -> None:
156
+ self._session_id = session_id or str(uuid.uuid4())
157
+ self._model = model
158
+ self._mcp_servers = mcp_servers
159
+ self._system_prompt = system_prompt
160
+ self._disallowed_tools = disallowed_tools
161
+ self._reasoning = reasoning
162
+ self._created_at = datetime.now(timezone.utc).isoformat()
163
+ self._has_sent = False
164
+ self._turns: list[Turn] = []
165
+ self._total_usage = UsageStats()
166
+ self._total_duration_ms = 0
167
+ self._mcp_config_path: str | None = None
168
+ self._last_raw_output: str = ""
169
+ self._step_callback = None
170
+
171
+ # ------------------------------------------------------------------
172
+ # MCP config helpers
173
+ # ------------------------------------------------------------------
174
+
175
+ def _ensure_mcp_config(self) -> str | None:
176
+ """Write MCP server config to a temp file on first call, return path."""
177
+ if not self._mcp_servers:
178
+ return None
179
+ if self._mcp_config_path is not None:
180
+ return self._mcp_config_path
181
+
182
+ config: dict[str, Any] = {"mcpServers": {}}
183
+ for srv in self._mcp_servers:
184
+ if srv.url:
185
+ entry: dict[str, Any] = {"type": "http", "url": srv.url}
186
+ else:
187
+ entry = {"command": srv.command, "args": srv.args}
188
+ if srv.env:
189
+ entry["env"] = srv.env
190
+ config["mcpServers"][srv.name] = entry
191
+
192
+ fd, path = tempfile.mkstemp(suffix=".json", prefix="caw_mcp_")
193
+ with os.fdopen(fd, "w") as f:
194
+ json.dump(config, f)
195
+ self._mcp_config_path = path
196
+ return path
197
+
198
+ # ------------------------------------------------------------------
199
+ # Core send (streaming Popen)
200
+ # ------------------------------------------------------------------
201
+
202
+ def send(self, message: str) -> Turn:
203
+ display = get_global_display()
204
+
205
+ if display:
206
+ if not self._has_sent:
207
+ display.on_metadata(
208
+ agent="claude_code",
209
+ model=self._model or "",
210
+ session=self._session_id,
211
+ )
212
+ display.on_user_message(message)
213
+
214
+ cmd = [
215
+ "claude",
216
+ "-p",
217
+ "--verbose",
218
+ "--output-format",
219
+ "stream-json",
220
+ "--dangerously-skip-permissions",
221
+ ]
222
+
223
+ if self._disallowed_tools:
224
+ cmd += ["--disallowedTools", ",".join(self._disallowed_tools)]
225
+
226
+ if self._model:
227
+ cmd += ["--model", self._model]
228
+
229
+ if self._reasoning:
230
+ cmd += ["--effort", self._reasoning]
231
+
232
+ if not self._has_sent:
233
+ cmd += ["--session-id", self._session_id]
234
+ if self._system_prompt:
235
+ cmd += ["--system-prompt", self._system_prompt]
236
+ else:
237
+ cmd += ["--resume", self._session_id]
238
+
239
+ mcp_path = self._ensure_mcp_config()
240
+ if mcp_path:
241
+ cmd += ["--mcp-config", mcp_path]
242
+
243
+ # Accumulated state for event processing
244
+ blocks: list[ContentBlock] = []
245
+ tool_blocks: dict[str, ToolUse] = {}
246
+ usage = UsageStats()
247
+ duration_ms = 0
248
+ raw_lines: list[str] = []
249
+
250
+ try:
251
+ proc = subprocess.Popen(
252
+ cmd,
253
+ stdin=subprocess.PIPE,
254
+ stdout=subprocess.PIPE,
255
+ stderr=subprocess.PIPE,
256
+ text=True,
257
+ )
258
+ except FileNotFoundError:
259
+ raise RuntimeError("claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code")
260
+
261
+ _register_process(proc)
262
+ try:
263
+ # Write message to stdin, then close to signal EOF
264
+ proc.stdin.write(message) # type: ignore[union-attr]
265
+ proc.stdin.close() # type: ignore[union-attr]
266
+
267
+ # Stream stdout line by line
268
+ for line in proc.stdout: # type: ignore[union-attr]
269
+ line = line.rstrip("\n")
270
+ raw_lines.append(line)
271
+ stripped = line.strip()
272
+ if not stripped:
273
+ continue
274
+ try:
275
+ event = json.loads(stripped)
276
+ except json.JSONDecodeError:
277
+ continue
278
+
279
+ result = self._process_event(event, blocks, tool_blocks, display)
280
+ if result is not None:
281
+ usage, duration_ms = result
282
+ if self._step_callback and blocks:
283
+ self._step_callback(list(blocks))
284
+
285
+ # Read stderr after stdout is exhausted
286
+ stderr = proc.stderr.read() if proc.stderr else "" # type: ignore[union-attr]
287
+ proc.wait()
288
+
289
+ self._last_raw_output = "\n".join(raw_lines)
290
+
291
+ if proc.returncode != 0 and not raw_lines:
292
+ raise RuntimeError(f"claude CLI exited with code {proc.returncode}: {stderr}")
293
+
294
+ except (KeyboardInterrupt, Exception):
295
+ proc.kill()
296
+ proc.wait()
297
+ raise
298
+ finally:
299
+ _unregister_process(proc)
300
+
301
+ self._has_sent = True
302
+
303
+ turn = Turn(input=message, output=blocks, usage=usage, duration_ms=duration_ms)
304
+
305
+ if display:
306
+ display.on_turn_end(turn.result, usage, duration_ms)
307
+
308
+ self._turns.append(turn)
309
+ self._total_usage = self._total_usage + turn.usage
310
+ self._total_duration_ms += turn.duration_ms
311
+ return turn
312
+
313
+ # ------------------------------------------------------------------
314
+ # Usage-limit detection (called by core Session auto-wait loop)
315
+ # ------------------------------------------------------------------
316
+
317
+ def detect_usage_limit(self, turn: Turn) -> int | None:
318
+ """Detect Claude Code usage-limit messages in the turn's result text."""
319
+ return detect_usage_limit(turn.result)
320
+
321
+ def set_step_callback(self, callback):
322
+ self._step_callback = callback
323
+
324
+ # ------------------------------------------------------------------
325
+ # Per-event processing
326
+ # ------------------------------------------------------------------
327
+
328
+ def _process_event(
329
+ self,
330
+ event: dict[str, Any],
331
+ blocks: list[ContentBlock],
332
+ tool_blocks: dict[str, ToolUse],
333
+ display: Display | None,
334
+ ) -> tuple[UsageStats, int] | None:
335
+ """Process a single JSONL event. Returns (usage, duration_ms) on 'result' events."""
336
+ event_type = event.get("type")
337
+
338
+ if event_type == "system" and event.get("subtype") == "init":
339
+ if not self._model:
340
+ self._model = event.get("model", "")
341
+ if display and self._model:
342
+ display.on_metadata(model=self._model)
343
+
344
+ elif event_type == "assistant":
345
+ new_blocks = self._parse_assistant_blocks(event)
346
+ for block in new_blocks:
347
+ blocks.append(block)
348
+ if display:
349
+ if isinstance(block, TextBlock):
350
+ display.on_text(block)
351
+ elif isinstance(block, ThinkingBlock):
352
+ display.on_thinking(block)
353
+ elif isinstance(block, ToolUse):
354
+ display.on_tool_call(block)
355
+ if isinstance(block, ToolUse):
356
+ tool_blocks[block.id] = block
357
+
358
+ elif event_type == "user":
359
+ # User events carry tool results — pair eagerly
360
+ msg_data = event.get("message", {})
361
+ for content in msg_data.get("content", []):
362
+ if content.get("type") == "tool_result":
363
+ tid = content.get("tool_use_id", "")
364
+ if tid:
365
+ text_parts: list[str] = []
366
+ raw_content = content.get("content", "")
367
+ if isinstance(raw_content, str):
368
+ text_parts.append(raw_content)
369
+ elif isinstance(raw_content, list):
370
+ for part in raw_content:
371
+ if isinstance(part, dict) and part.get("type") == "text":
372
+ text_parts.append(part.get("text", ""))
373
+ output = "\n".join(text_parts)
374
+ # HTTP MCP transport wraps results in {"result": "..."}
375
+ try:
376
+ parsed = json.loads(output)
377
+ if isinstance(parsed, dict) and "result" in parsed:
378
+ output = str(parsed["result"])
379
+ except (json.JSONDecodeError, TypeError, ValueError):
380
+ pass
381
+ is_error = content.get("is_error", False)
382
+
383
+ if tid in tool_blocks:
384
+ tool_blocks[tid].output = output
385
+ tool_blocks[tid].is_error = is_error
386
+ if display:
387
+ display.on_tool_result(tool_blocks[tid])
388
+
389
+ elif event_type == "result":
390
+ return self._parse_usage(event), event.get("duration_ms", 0)
391
+
392
+ return None
393
+
394
+ # ------------------------------------------------------------------
395
+ # Static helpers
396
+ # ------------------------------------------------------------------
397
+
398
+ @staticmethod
399
+ def _parse_assistant_blocks(event: dict[str, Any]) -> list[ContentBlock]:
400
+ """Parse an assistant event into content blocks."""
401
+ msg_data = event.get("message", {})
402
+ content_blocks = msg_data.get("content", [])
403
+ if not content_blocks:
404
+ return []
405
+
406
+ result: list[ContentBlock] = []
407
+
408
+ for block in content_blocks:
409
+ block_type = block.get("type")
410
+ if block_type == "text":
411
+ text = block.get("text", "")
412
+ if text:
413
+ result.append(TextBlock(text=text))
414
+ elif block_type == "thinking":
415
+ text = block.get("thinking", "")
416
+ if text:
417
+ result.append(ThinkingBlock(text=text))
418
+ elif block_type == "tool_use":
419
+ result.append(
420
+ ToolUse(
421
+ id=block.get("id", ""),
422
+ name=block.get("name", ""),
423
+ arguments=block.get("input", {}),
424
+ )
425
+ )
426
+
427
+ return result
428
+
429
+ @staticmethod
430
+ def _parse_usage(event: dict[str, Any]) -> UsageStats:
431
+ u = event.get("usage", {})
432
+ return UsageStats(
433
+ input_tokens=u.get("input_tokens", 0),
434
+ output_tokens=u.get("output_tokens", 0),
435
+ cache_read_tokens=u.get("cache_read_input_tokens", 0),
436
+ cache_write_tokens=u.get("cache_creation_input_tokens", 0),
437
+ cost_usd=event.get("total_cost_usd", 0.0),
438
+ )
439
+
440
+ # ------------------------------------------------------------------
441
+ # Trajectory / lifecycle
442
+ # ------------------------------------------------------------------
443
+
444
+ @property
445
+ def session_id(self) -> str:
446
+ return self._session_id
447
+
448
+ @property
449
+ def last_raw_output(self) -> str:
450
+ return self._last_raw_output
451
+
452
+ @property
453
+ def trajectory(self) -> Trajectory:
454
+ return Trajectory(
455
+ agent="claude_code",
456
+ model=self._model or "",
457
+ session_id=self._session_id,
458
+ created_at=self._created_at,
459
+ system_prompt=self._system_prompt or "",
460
+ mcp_servers=list(self._mcp_servers),
461
+ turns=list(self._turns),
462
+ usage=self._total_usage,
463
+ duration_ms=self._total_duration_ms,
464
+ metadata={},
465
+ )
466
+
467
+ def end(self) -> Trajectory:
468
+ traj = self.trajectory
469
+ if self._mcp_config_path and os.path.exists(self._mcp_config_path):
470
+ os.unlink(self._mcp_config_path)
471
+ self._mcp_config_path = None
472
+ return traj
473
+
474
+
475
+ class ClaudeCodeProvider(Provider):
476
+ """Provider that delegates to the ``claude`` CLI."""
477
+
478
+ @property
479
+ def name(self) -> str:
480
+ return "claude_code"
481
+
482
+ def resolve_model(self, tier: ModelTier) -> str:
483
+ return _MODEL_TIER_MAP[tier]
484
+
485
+ def resolve_tool_restrictions(self, tools: ToolGroup) -> dict[str, Any]:
486
+ if tools == ToolGroup.ALL:
487
+ return {}
488
+ if not tools:
489
+ raise ValueError("ToolGroup must not be empty — at least one group is required.")
490
+ disallowed: list[str] = []
491
+ for group, names in _TOOL_GROUP_MAP.items():
492
+ if not (tools & group):
493
+ disallowed.extend(names)
494
+ if not disallowed:
495
+ return {}
496
+ return {"disallowed_tools": disallowed}
497
+
498
+ def _limit_probe_kwargs(self) -> dict[str, Any]:
499
+ all_tools: list[str] = []
500
+ for names in _TOOL_GROUP_MAP.values():
501
+ all_tools.extend(names)
502
+ return {"disallowed_tools": all_tools}
503
+
504
+ def start_interactive(
505
+ self, initial_prompt: str, mcp_servers: list[MCPServer], capture_bytes: int = 0, **kwargs: Any
506
+ ) -> InteractiveResult:
507
+ import pty
508
+
509
+ cmd = ["claude"]
510
+
511
+ model = kwargs.get("model")
512
+ if model:
513
+ cmd += ["--model", model]
514
+
515
+ system_prompt = kwargs.get("system_prompt")
516
+ if system_prompt:
517
+ cmd += ["--system-prompt", system_prompt]
518
+
519
+ reasoning = kwargs.get("reasoning")
520
+ if reasoning:
521
+ cmd += ["--effort", reasoning]
522
+
523
+ disallowed_tools = kwargs.get("disallowed_tools")
524
+ if disallowed_tools:
525
+ cmd += ["--disallowedTools", ",".join(disallowed_tools)]
526
+
527
+ # Write MCP config if servers are provided
528
+ mcp_config_path: str | None = None
529
+ if mcp_servers:
530
+ config: dict[str, Any] = {"mcpServers": {}}
531
+ for srv in mcp_servers:
532
+ if srv.url:
533
+ entry: dict[str, Any] = {"type": "http", "url": srv.url}
534
+ else:
535
+ entry = {"command": srv.command, "args": srv.args}
536
+ if srv.env:
537
+ entry["env"] = srv.env
538
+ config["mcpServers"][srv.name] = entry
539
+ fd, mcp_config_path = tempfile.mkstemp(suffix=".json", prefix="caw_mcp_")
540
+ with os.fdopen(fd, "w") as f:
541
+ json.dump(config, f)
542
+ cmd += ["--mcp-config", mcp_config_path]
543
+
544
+ # Initial prompt as positional argument
545
+ cmd.append(initial_prompt)
546
+
547
+ # Use a pty to capture a copy of stdout while the user
548
+ # interacts with the full TUI normally.
549
+ import fcntl
550
+ import select
551
+ import signal
552
+ import termios
553
+ import tty
554
+
555
+ captured = bytearray()
556
+ cap_limit = capture_bytes if capture_bytes > 0 else 0
557
+ master_fd, slave_fd = pty.openpty()
558
+
559
+ # Propagate the real terminal size to the pty
560
+ def _copy_winsize(from_fd: int, to_fd: int) -> None:
561
+ winsize = fcntl.ioctl(from_fd, termios.TIOCGWINSZ, b"\x00" * 8)
562
+ fcntl.ioctl(to_fd, termios.TIOCSWINSZ, winsize)
563
+
564
+ _copy_winsize(pty.STDOUT_FILENO, master_fd)
565
+
566
+ pid = os.fork()
567
+ if pid == 0:
568
+ # Child: become session leader, set slave as controlling terminal
569
+ os.close(master_fd)
570
+ os.setsid()
571
+ fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
572
+ os.dup2(slave_fd, 0)
573
+ os.dup2(slave_fd, 1)
574
+ os.dup2(slave_fd, 2)
575
+ if slave_fd > 2:
576
+ os.close(slave_fd)
577
+ os.execvp(cmd[0], cmd)
578
+
579
+ # Parent
580
+ os.close(slave_fd)
581
+
582
+ # Forward SIGWINCH to the child pty
583
+ old_sigwinch = signal.getsignal(signal.SIGWINCH)
584
+
585
+ def _on_winch(signum: int, frame: Any) -> None:
586
+ _copy_winsize(pty.STDOUT_FILENO, master_fd)
587
+ os.kill(pid, signal.SIGWINCH)
588
+
589
+ signal.signal(signal.SIGWINCH, _on_winch)
590
+
591
+ # Save and set raw mode on the real terminal
592
+ old_attrs = termios.tcgetattr(pty.STDIN_FILENO)
593
+ tty.setraw(pty.STDIN_FILENO)
594
+
595
+ try:
596
+ while True:
597
+ try:
598
+ fds = select.select([pty.STDIN_FILENO, master_fd], [], [], 0.1)[0]
599
+ except select.error:
600
+ continue
601
+ if master_fd in fds:
602
+ try:
603
+ data = os.read(master_fd, 4096)
604
+ except OSError:
605
+ break
606
+ if not data:
607
+ break
608
+ captured.extend(data)
609
+ if cap_limit and len(captured) > cap_limit:
610
+ del captured[: len(captured) - cap_limit]
611
+ os.write(pty.STDOUT_FILENO, data)
612
+ if pty.STDIN_FILENO in fds:
613
+ data = os.read(pty.STDIN_FILENO, 4096)
614
+ if not data:
615
+ break
616
+ os.write(master_fd, data)
617
+
618
+ _, status = os.waitpid(pid, 0)
619
+ exit_code = os.waitstatus_to_exitcode(status)
620
+ except (KeyboardInterrupt, Exception):
621
+ os.kill(pid, signal.SIGTERM)
622
+ os.waitpid(pid, 0)
623
+ raise
624
+ finally:
625
+ # Restore terminal state, signal handler, and clean up
626
+ termios.tcsetattr(pty.STDIN_FILENO, termios.TCSAFLUSH, old_attrs)
627
+ signal.signal(signal.SIGWINCH, old_sigwinch)
628
+ os.close(master_fd)
629
+ if mcp_config_path and os.path.exists(mcp_config_path):
630
+ os.unlink(mcp_config_path)
631
+
632
+ output = captured.decode("utf-8", errors="replace")
633
+ return InteractiveResult(exit_code=exit_code, output=output)
634
+
635
+ def start_session(self, mcp_servers: list[MCPServer], **kwargs: Any) -> ClaudeCodeSession:
636
+ model = kwargs.get("model")
637
+ system_prompt = kwargs.get("system_prompt")
638
+ session_id = kwargs.get("session_id")
639
+ disallowed_tools = kwargs.get("disallowed_tools")
640
+ reasoning = kwargs.get("reasoning")
641
+ return ClaudeCodeSession(
642
+ mcp_servers=mcp_servers,
643
+ model=model,
644
+ system_prompt=system_prompt,
645
+ session_id=session_id,
646
+ disallowed_tools=disallowed_tools,
647
+ reasoning=reasoning,
648
+ )