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.
- caw/__init__.py +88 -0
- caw/agent.py +578 -0
- caw/auth/README.md +118 -0
- caw/auth/__init__.py +23 -0
- caw/auth/cli.py +68 -0
- caw/auth/collector.py +324 -0
- caw/auth/linker.py +174 -0
- caw/auth/manifest.py +77 -0
- caw/auth/providers.py +433 -0
- caw/auth/status.py +241 -0
- caw/cli.py +50 -0
- caw/display.py +223 -0
- caw/faststats.py +298 -0
- caw/mcp.py +602 -0
- caw/models.py +385 -0
- caw/pricing.json +15 -0
- caw/pricing.py +33 -0
- caw/provider.py +135 -0
- caw/providers/__init__.py +0 -0
- caw/providers/claude_code.py +648 -0
- caw/providers/codex.py +564 -0
- caw/py.typed +0 -0
- caw/storage.py +184 -0
- caw/toolkit.py +198 -0
- caw/viewer/__init__.py +149 -0
- caw/viewer/static/index.html +847 -0
- coding_agent_wrapper-0.1.0.dist-info/METADATA +213 -0
- coding_agent_wrapper-0.1.0.dist-info/RECORD +31 -0
- coding_agent_wrapper-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_wrapper-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
+
)
|