anywhere-cli 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,1377 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import secrets
9
+ from collections.abc import Awaitable, Callable
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+ from connector.attachments import attachment_target
17
+ from connector.adapter import NotificationSink
18
+ from connector.claude.history_adapter import ClaudeHistoryAdapter
19
+ from connector.claude.normalized import NormalizedClaudeEvent
20
+ from connector.claude.normalizers import ClaudeLiveNormalizer
21
+ from connector.claude.timeline_reducer import ClaudeTimelineReducer, is_task_event_tool_name
22
+ from connector.launch import LaunchTarget, launch_target
23
+ from connector.time import utc_now
24
+
25
+
26
+ AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
27
+ """(session_id, file_id) -> (data, original_name, media_type)"""
28
+
29
+ _MAX_STDERR_LINES = 80
30
+ _MAX_STDERR_CHARS = 8000
31
+ _SECRET_RE = re.compile(
32
+ r"(?i)(api[_-]?key|auth[_-]?token|authorization|bearer|token|password|secret)([=:\s]+)([^\s,;]+)"
33
+ )
34
+
35
+
36
+ class ClaudeSdkAdapterError(RuntimeError):
37
+ pass
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class _PendingSdkApproval:
42
+ approval_id: str
43
+ future: asyncio.Future[str]
44
+ input_data: dict[str, Any]
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class _SdkSessionRuntime:
49
+ session_id: str
50
+ connector_id: str | None = None
51
+ cwd: str | None = None
52
+ external_session_id: str | None = None
53
+ client: Any | None = None
54
+ active_task: asyncio.Task[None] | None = None
55
+ active_turn_id: str | None = None
56
+ next_order_seq: int = 1
57
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
58
+ pending_approvals: dict[str, _PendingSdkApproval] = field(default_factory=dict)
59
+ interrupted: bool = False
60
+ stderr_lines: list[str] = field(default_factory=list)
61
+ current_client_message_id: str | None = None
62
+ current_content: str | None = None
63
+ current_attachments: list[dict[str, Any]] | None = None
64
+ emitted_user_message: bool = False
65
+ partial_message_id: str | None = None
66
+ partial_message_uuid: str | None = None
67
+ partial_text_blocks: dict[int, str] = field(default_factory=dict)
68
+ live_stream_items: dict[str, dict[str, Any]] = field(default_factory=dict)
69
+ live_tool_items: dict[str, dict[str, Any]] = field(default_factory=dict)
70
+ ignored_task_tool_use_ids: set[str] = field(default_factory=set)
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class ClaudeSdkAdapter:
75
+ """Claude Chat Mode adapter backed by the Python Claude Agent SDK."""
76
+
77
+ notification_sink: NotificationSink = None
78
+ sdk_module: Any | None = None
79
+ history_adapter: ClaudeHistoryAdapter = field(default_factory=ClaudeHistoryAdapter)
80
+ attachment_downloader: AttachmentDownloader | None = None
81
+ claude_target: LaunchTarget | None = None
82
+ _sessions: dict[str, _SdkSessionRuntime] = field(default_factory=dict, init=False)
83
+
84
+ @property
85
+ def claude_bin(self) -> str | None:
86
+ return self.claude_target.path if self.claude_target is not None else None
87
+
88
+ @claude_bin.setter
89
+ def claude_bin(self, value: str | None) -> None:
90
+ self.claude_target = launch_target("cli", value) if value else None
91
+
92
+ def forget_sync_state(self) -> None:
93
+ self.history_adapter.forget_sync_state()
94
+
95
+ def forget_persisted_sync_state(self, connector_id: str) -> None:
96
+ self.history_adapter.forget_persisted_sync_state(connector_id)
97
+
98
+ def apply_history_sync_state(self, state: list[dict[str, Any]]) -> None:
99
+ self.history_adapter.apply_history_sync_state(state)
100
+
101
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
102
+ session_id = (
103
+ _optional_string(params.get("sessionId"))
104
+ or f"sess_claude_chat_{secrets.token_urlsafe(10)}"
105
+ )
106
+ runtime = self._runtime_for(session_id, params)
107
+ return {
108
+ "sessionId": session_id,
109
+ "externalSessionId": runtime.external_session_id,
110
+ "backendNotifications": [],
111
+ }
112
+
113
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
114
+ self._prepare_history_adapter()
115
+ return await self.history_adapter.sync_session(params)
116
+
117
+ async def sync_existing_sessions(
118
+ self,
119
+ connector_id: str,
120
+ *,
121
+ limit: int = 100,
122
+ force: bool = False,
123
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
124
+ ) -> dict[str, Any]:
125
+ self._prepare_history_adapter()
126
+ skip_external_session_ids = {
127
+ runtime.external_session_id
128
+ for runtime in self._sessions.values()
129
+ if runtime.active_turn_id is not None and runtime.external_session_id is not None
130
+ }
131
+ return await self.history_adapter.sync_existing_sessions(
132
+ connector_id,
133
+ limit=limit,
134
+ force=force,
135
+ skip_external_session_ids=skip_external_session_ids,
136
+ notification_sink=notification_sink,
137
+ )
138
+
139
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
140
+ session_id = _required(params, "sessionId")
141
+ content = _required(params, "content")
142
+ runtime = self._runtime_for(session_id, params)
143
+ connector_id = _optional_string(params.get("connectorId"))
144
+ if connector_id is not None:
145
+ runtime.connector_id = connector_id
146
+ if runtime.lock.locked():
147
+ raise ClaudeSdkAdapterError("Claude SDK turn already running for this session")
148
+ await runtime.lock.acquire()
149
+ runtime.interrupted = False
150
+ turn_id = _optional_string(params.get("turnId")) or _turn_id(session_id, content)
151
+ runtime.active_turn_id = turn_id
152
+ runtime.current_client_message_id = _optional_string(params.get("clientMessageId"))
153
+ runtime.current_content = content
154
+ runtime.current_attachments = _attachments_metadata(params)
155
+ runtime.emitted_user_message = False
156
+ runtime.partial_message_id = None
157
+ runtime.partial_message_uuid = None
158
+ runtime.partial_text_blocks.clear()
159
+ runtime.live_stream_items.clear()
160
+ runtime.live_tool_items.clear()
161
+ runtime.ignored_task_tool_use_ids.clear()
162
+ runtime.active_task = asyncio.create_task(
163
+ self._drive_turn(runtime=runtime, params=params, content=content, turn_id=turn_id)
164
+ )
165
+ self._prepare_history_adapter()
166
+ runtime.active_task.add_done_callback(
167
+ lambda _task: runtime.lock.release() if runtime.lock.locked() else None
168
+ )
169
+ return {"turnId": turn_id}
170
+
171
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]:
172
+ runtime = self._sessions.get(_required(params, "sessionId"))
173
+ if runtime is None:
174
+ return {"interrupted": False, "reason": "session not registered"}
175
+ runtime.interrupted = True
176
+ for pending in list(runtime.pending_approvals.values()):
177
+ if not pending.future.done():
178
+ pending.future.set_result("cancelled")
179
+ client = runtime.client
180
+ if client is not None:
181
+ interrupt = getattr(client, "interrupt", None)
182
+ if callable(interrupt):
183
+ await interrupt()
184
+ return {"interrupted": True}
185
+ return {"interrupted": False, "reason": "no active Claude SDK client"}
186
+
187
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]:
188
+ session_id = _required(params, "sessionId")
189
+ approval_id = _required(params, "approvalId")
190
+ status = _required(params, "status")
191
+ runtime = self._sessions.get(session_id)
192
+ if runtime is None:
193
+ return {"resolved": False, "reason": "session not registered"}
194
+ pending = runtime.pending_approvals.get(approval_id)
195
+ if pending is None:
196
+ return {"resolved": False, "reason": "approval not pending"}
197
+ if not pending.future.done():
198
+ pending.future.set_result(status)
199
+ return {"resolved": True}
200
+
201
+ def _runtime_for(self, session_id: str, params: dict[str, Any]) -> _SdkSessionRuntime:
202
+ runtime = self._sessions.get(session_id)
203
+ if runtime is None:
204
+ runtime = _SdkSessionRuntime(
205
+ session_id=session_id,
206
+ cwd=_optional_string(params.get("cwd")),
207
+ external_session_id=_optional_string(params.get("externalSessionId")),
208
+ )
209
+ self._sessions[session_id] = runtime
210
+ if params.get("cwd"):
211
+ runtime.cwd = _optional_string(params.get("cwd"))
212
+ if params.get("externalSessionId"):
213
+ runtime.external_session_id = _optional_string(params.get("externalSessionId"))
214
+ return runtime
215
+
216
+ async def _drive_turn(
217
+ self,
218
+ *,
219
+ runtime: _SdkSessionRuntime,
220
+ params: dict[str, Any],
221
+ content: str,
222
+ turn_id: str,
223
+ ) -> None:
224
+ stream_finished = False
225
+ try:
226
+ runtime.stderr_lines.clear()
227
+ await self._emit_item(runtime.session_id, _turn_start_item(runtime, turn_id))
228
+ client = self._client(runtime, params)
229
+ runtime.client = client
230
+ await _maybe_await(getattr(client, "connect", None))
231
+ runtime_content = await self._materialize_runtime_content(
232
+ content=content,
233
+ attachments=params.get("attachments"),
234
+ cwd=runtime.cwd,
235
+ session_id=runtime.session_id,
236
+ )
237
+ await client.query(_prompt_stream(runtime_content))
238
+ await self._receive_response(runtime, client, turn_id)
239
+ stream_finished = True
240
+ except asyncio.CancelledError:
241
+ raise
242
+ except Exception as exc:
243
+ stderr = _stderr_excerpt(runtime.stderr_lines)
244
+ logger.exception(
245
+ "claude sdk turn failed session_id={} turn_id={} cwd={} external_session_id={} "
246
+ "model={} effort={} permission_mode={} cli_path={} stderr={}",
247
+ runtime.session_id,
248
+ turn_id,
249
+ runtime.cwd,
250
+ runtime.external_session_id,
251
+ _optional_string(params.get("model")),
252
+ _optional_string(params.get("effort")),
253
+ _optional_string(params.get("permissionMode")),
254
+ self.claude_bin,
255
+ stderr or "<empty>",
256
+ )
257
+ stop_reason = _failure_message(exc, stderr)
258
+ await self._finalize_live_stream_items(runtime, turn_id, status="failed")
259
+ await self._emit_item(
260
+ runtime.session_id,
261
+ _turn_end_item(
262
+ runtime,
263
+ turn_id,
264
+ status="failed",
265
+ result="failed",
266
+ stop_reason=stop_reason,
267
+ ),
268
+ )
269
+ if self.notification_sink is not None:
270
+ await self.notification_sink(
271
+ "runtime.error",
272
+ {
273
+ "sessionId": runtime.session_id,
274
+ "runtime": "claude",
275
+ "message": stop_reason,
276
+ "stderr": stderr,
277
+ },
278
+ )
279
+ finally:
280
+ if stream_finished:
281
+ await self._mark_history_consumed(runtime)
282
+ if runtime.active_turn_id == turn_id:
283
+ runtime.active_turn_id = None
284
+ runtime.active_task = None
285
+ runtime.current_client_message_id = None
286
+ runtime.current_content = None
287
+ runtime.current_attachments = None
288
+ runtime.emitted_user_message = False
289
+ runtime.pending_approvals.clear()
290
+ self._prepare_history_adapter()
291
+ await self._emit_session_update(runtime, status="idle")
292
+
293
+ async def _receive_response(self, runtime: _SdkSessionRuntime, client: Any, turn_id: str) -> None:
294
+ receive_response = getattr(client, "receive_response", None)
295
+ if not callable(receive_response):
296
+ raise ClaudeSdkAdapterError("ClaudeSDKClient does not expose receive_response()")
297
+ saw_result = False
298
+ emitted_live_content = False
299
+ buffered_messages: list[Any] = []
300
+ async for message in receive_response():
301
+ if _is_stream_event(message):
302
+ session_id = _optional_string(_extract_attr(message, "session_id", "sessionId"))
303
+ if session_id:
304
+ runtime.external_session_id = session_id
305
+ self._prepare_history_adapter()
306
+ await self._emit_session_update(runtime, status="running")
307
+ if runtime.external_session_id is None:
308
+ buffered_messages.append(message)
309
+ continue
310
+ await self._emit_pending_user_message(runtime, turn_id)
311
+ emitted_live_content = await self._emit_stream_event(runtime, turn_id, message) or emitted_live_content
312
+ continue
313
+ if _is_result_message(message):
314
+ saw_result = True
315
+ session_id = _extract_attr(message, "session_id", "sessionId")
316
+ if isinstance(session_id, str) and session_id:
317
+ runtime.external_session_id = session_id
318
+ self._prepare_history_adapter()
319
+ await self._emit_session_update(runtime, status="running")
320
+ await self._emit_pending_user_message(runtime, turn_id)
321
+ for buffered in buffered_messages:
322
+ if _is_stream_event(buffered):
323
+ emitted_live_content = await self._emit_stream_event(runtime, turn_id, buffered) or emitted_live_content
324
+ else:
325
+ emitted_live_content = await self._emit_sdk_message(runtime, turn_id, buffered) or emitted_live_content
326
+ if not emitted_live_content:
327
+ emitted_live_content = await self._emit_result_message(runtime, turn_id, message) or emitted_live_content
328
+ subtype = _optional_string(_extract_attr(message, "subtype"))
329
+ status = "interrupted" if runtime.interrupted else ("failed" if subtype in {"error", "failed"} else "done")
330
+ result = "interrupted" if runtime.interrupted else ("failed" if status == "failed" else "completed")
331
+ await self._finalize_live_stream_items(runtime, turn_id, status=status)
332
+ await self._emit_item(
333
+ runtime.session_id,
334
+ _turn_end_item(
335
+ runtime,
336
+ turn_id,
337
+ status=status,
338
+ result=result,
339
+ stop_reason=subtype or result,
340
+ ),
341
+ )
342
+ break
343
+ if runtime.external_session_id is None:
344
+ buffered_messages.append(message)
345
+ continue
346
+ await self._emit_pending_user_message(runtime, turn_id)
347
+ emitted_live_content = await self._emit_sdk_message(runtime, turn_id, message) or emitted_live_content
348
+ if not saw_result:
349
+ status = "interrupted" if runtime.interrupted else "done"
350
+ await self._emit_pending_user_message(runtime, turn_id)
351
+ for buffered in buffered_messages:
352
+ if _is_stream_event(buffered):
353
+ await self._emit_stream_event(runtime, turn_id, buffered)
354
+ else:
355
+ await self._emit_sdk_message(runtime, turn_id, buffered)
356
+ await self._finalize_live_stream_items(runtime, turn_id, status=status)
357
+ await self._emit_item(
358
+ runtime.session_id,
359
+ _turn_end_item(
360
+ runtime,
361
+ turn_id,
362
+ status=status,
363
+ result="interrupted" if runtime.interrupted else "completed",
364
+ stop_reason="interrupted" if runtime.interrupted else "completed",
365
+ ),
366
+ )
367
+
368
+ async def _emit_sdk_message(self, runtime: _SdkSessionRuntime, turn_id: str, message: Any) -> bool:
369
+ role = _message_role(message)
370
+ raw = _sdk_message_to_raw(
371
+ message,
372
+ runtime.external_session_id,
373
+ )
374
+ if raw is not None:
375
+ return await self._emit_normalized(
376
+ runtime.session_id,
377
+ turn_id,
378
+ raw,
379
+ streaming=role == "assistant",
380
+ )
381
+ return False
382
+
383
+ async def _emit_stream_event(self, runtime: _SdkSessionRuntime, turn_id: str, message: Any) -> bool:
384
+ raw = _stream_event_to_raw(runtime, turn_id, message)
385
+ if raw is not None:
386
+ return await self._emit_normalized(runtime.session_id, turn_id, raw, streaming=True)
387
+ return False
388
+
389
+ async def _emit_result_message(self, runtime: _SdkSessionRuntime, turn_id: str, message: Any) -> bool:
390
+ raw = _result_message_to_raw(message, runtime.external_session_id)
391
+ if raw is not None:
392
+ return await self._emit_normalized(runtime.session_id, turn_id, raw)
393
+ return False
394
+
395
+ async def _emit_normalized(
396
+ self,
397
+ session_id: str,
398
+ turn_id: str,
399
+ raw: dict[str, Any],
400
+ *,
401
+ streaming: bool = False,
402
+ ) -> bool:
403
+ reducer = ClaudeTimelineReducer()
404
+ events = ClaudeLiveNormalizer().normalize([raw])
405
+ runtime = self._sessions.get(session_id)
406
+ if runtime is not None:
407
+ events = _filter_live_task_events(runtime, events)
408
+ emitted = False
409
+ for item in reducer.reduce(session_id=session_id, turn_id=turn_id, events=events):
410
+ dumped = dict(item)
411
+ if runtime is not None:
412
+ if streaming and _is_streaming_assistant_message(dumped):
413
+ prepared = _prepare_live_stream_item(runtime, dumped)
414
+ if prepared is None:
415
+ continue
416
+ dumped = prepared
417
+ elif _is_streaming_assistant_message(dumped):
418
+ prepared = _prepare_live_stream_final_item(runtime, dumped)
419
+ if prepared is not None:
420
+ dumped = prepared
421
+ else:
422
+ dumped["orderSeq"] = _next_order(runtime)
423
+ elif _is_tool_item(dumped):
424
+ prepared = _prepare_live_tool_item(runtime, dumped)
425
+ if prepared is None:
426
+ continue
427
+ dumped = prepared
428
+ else:
429
+ dumped["orderSeq"] = _next_order(runtime)
430
+ await self._emit_item(session_id, dumped)
431
+ emitted = True
432
+ return emitted
433
+
434
+ async def _finalize_live_stream_items(
435
+ self,
436
+ runtime: _SdkSessionRuntime,
437
+ turn_id: str,
438
+ *,
439
+ status: str,
440
+ ) -> None:
441
+ if not runtime.live_stream_items:
442
+ return
443
+ completed_at = utc_now()
444
+ for item_id, item in list(runtime.live_stream_items.items()):
445
+ if item.get("turnId") != turn_id:
446
+ continue
447
+ if item.get("status") == status and item.get("completedAt"):
448
+ continue
449
+ finalized = dict(item)
450
+ finalized["status"] = status
451
+ finalized["revision"] = int(finalized.get("revision") or 1) + 1
452
+ finalized["updatedAt"] = completed_at
453
+ finalized["completedAt"] = completed_at
454
+ runtime.live_stream_items[item_id] = finalized
455
+ await self._emit_item(runtime.session_id, finalized)
456
+
457
+ async def _emit_pending_user_message(self, runtime: _SdkSessionRuntime, turn_id: str) -> None:
458
+ if runtime.emitted_user_message:
459
+ return
460
+ if not runtime.external_session_id or runtime.current_content is None:
461
+ return
462
+ events = [
463
+ NormalizedClaudeEvent(
464
+ claudeSessionId=runtime.external_session_id,
465
+ sourceEventId=f"{turn_id}:user",
466
+ messageId=f"{turn_id}:user",
467
+ role="user",
468
+ blockIndex=0,
469
+ blockType="text",
470
+ text=runtime.current_content,
471
+ timestamp=utc_now(),
472
+ clientMessageId=runtime.current_client_message_id,
473
+ attachments=runtime.current_attachments,
474
+ )
475
+ ]
476
+ for item in ClaudeTimelineReducer().reduce(
477
+ session_id=runtime.session_id,
478
+ turn_id=turn_id,
479
+ events=events,
480
+ ):
481
+ item["orderSeq"] = _next_order(runtime)
482
+ await self._emit_item(runtime.session_id, item)
483
+ runtime.emitted_user_message = True
484
+
485
+ async def _emit_item(self, session_id: str, item: dict[str, Any]) -> None:
486
+ if self.notification_sink is None:
487
+ return
488
+ await self.notification_sink("timeline.itemUpsert", {"sessionId": session_id, "item": item})
489
+
490
+ async def _emit_session_update(self, runtime: _SdkSessionRuntime, *, status: str) -> None:
491
+ if self.notification_sink is None:
492
+ return
493
+ await self.notification_sink(
494
+ "session.updated",
495
+ {
496
+ "sessionId": runtime.session_id,
497
+ "runtime": "claude",
498
+ "externalSessionId": runtime.external_session_id,
499
+ "status": status,
500
+ "cwd": runtime.cwd,
501
+ "lastSyncedAt": utc_now(),
502
+ },
503
+ )
504
+
505
+ async def _mark_history_consumed(self, runtime: _SdkSessionRuntime) -> None:
506
+ try:
507
+ self._prepare_history_adapter()
508
+ await self.history_adapter.mark_session_consumed(
509
+ connector_id=runtime.connector_id,
510
+ external_session_id=runtime.external_session_id,
511
+ cwd=runtime.cwd,
512
+ )
513
+ except Exception:
514
+ logger.exception(
515
+ "claude sdk history consumed marker failed session_id={} external_session_id={}",
516
+ runtime.session_id,
517
+ runtime.external_session_id,
518
+ )
519
+
520
+ async def _sync_current_history_snapshot(self, runtime: _SdkSessionRuntime) -> None:
521
+ if runtime.external_session_id is None:
522
+ return
523
+ try:
524
+ self._prepare_history_adapter()
525
+ result = await self.history_adapter.sync_session(
526
+ {
527
+ "sessionId": runtime.session_id,
528
+ "externalSessionId": runtime.external_session_id,
529
+ "cwd": runtime.cwd,
530
+ "pendingClientMessages": _pending_client_messages(runtime),
531
+ }
532
+ )
533
+ except Exception:
534
+ logger.exception(
535
+ "claude sdk history snapshot failed session_id={} external_session_id={}",
536
+ runtime.session_id,
537
+ runtime.external_session_id,
538
+ )
539
+ return
540
+ notifications = result.get("backendNotifications") if isinstance(result, dict) else None
541
+ if self.notification_sink is None or not isinstance(notifications, list):
542
+ return
543
+ for notification in notifications:
544
+ if not isinstance(notification, dict):
545
+ continue
546
+ method = notification.get("method")
547
+ params = notification.get("params")
548
+ if isinstance(method, str) and isinstance(params, dict):
549
+ await self.notification_sink(method, params)
550
+
551
+ def _prepare_history_adapter(self) -> None:
552
+ self.history_adapter.sdk_module = self.sdk_module
553
+
554
+ def _client(self, runtime: _SdkSessionRuntime, params: dict[str, Any]) -> Any:
555
+ sdk = self._load_sdk()
556
+ options = sdk.ClaudeAgentOptions(**self._options_kwargs(sdk, runtime, params))
557
+ client_cls = sdk.ClaudeSDKClient
558
+ try:
559
+ return client_cls(options=options)
560
+ except TypeError:
561
+ return client_cls(options)
562
+
563
+ def _options_kwargs(self, sdk: Any, runtime: _SdkSessionRuntime, params: dict[str, Any]) -> dict[str, Any]:
564
+ kwargs: dict[str, Any] = {
565
+ "include_partial_messages": True,
566
+ "can_use_tool": self._can_use_tool,
567
+ "stderr": lambda line: _record_stderr(runtime, line),
568
+ }
569
+ if runtime.cwd:
570
+ kwargs["cwd"] = runtime.cwd
571
+ if runtime.external_session_id:
572
+ kwargs["resume"] = runtime.external_session_id
573
+ if self.claude_target is not None:
574
+ kwargs["cli_path"] = self.claude_target.path
575
+ for param_key, option_key in (
576
+ ("permissionMode", "permission_mode"),
577
+ ("model", "model"),
578
+ ("effort", "effort"),
579
+ ):
580
+ value = _optional_string(params.get(param_key))
581
+ if value:
582
+ kwargs[option_key] = value
583
+ hook_matcher = _optional_attr(sdk, "HookMatcher", "types.HookMatcher")
584
+ if hook_matcher is not None:
585
+ async def _keep_permission_stream_open(_input_data: Any, _tool_use_id: Any = None, _context: Any = None) -> dict[str, bool]:
586
+ return {"continue_": True}
587
+
588
+ kwargs["hooks"] = {"PreToolUse": [hook_matcher(matcher=None, hooks=[_keep_permission_stream_open])]}
589
+ return kwargs
590
+
591
+ async def _can_use_tool(self, tool_name: str, input_data: dict[str, Any], context: Any = None) -> Any:
592
+ sdk = self._load_sdk()
593
+ context_session_id = _optional_string(_extract_attr(context, "session_id", "sessionId"))
594
+ runtime = self._runtime_from_context(context_session_id)
595
+ if runtime is None:
596
+ return _permission_deny(sdk, "Session is not registered")
597
+ approval_id = _approval_id(runtime.session_id, runtime.active_turn_id, tool_name, input_data)
598
+ loop = asyncio.get_running_loop()
599
+ future: asyncio.Future[str] = loop.create_future()
600
+ runtime.pending_approvals[approval_id] = _PendingSdkApproval(approval_id, future, input_data)
601
+ if self.notification_sink is not None:
602
+ await self.notification_sink(
603
+ "approval.requested",
604
+ _approval_payload(
605
+ approval_id=approval_id,
606
+ runtime=runtime,
607
+ tool_name=tool_name,
608
+ input_data=input_data,
609
+ ),
610
+ )
611
+ status = await future
612
+ runtime.pending_approvals.pop(approval_id, None)
613
+ if status in {"approved", "approved_for_session"} and not runtime.interrupted:
614
+ return _permission_allow(sdk, input_data)
615
+ return _permission_deny(sdk, "User denied or interrupted this action")
616
+
617
+ def _runtime_from_context(self, context_session_id: str | None) -> _SdkSessionRuntime | None:
618
+ if context_session_id:
619
+ for runtime in self._sessions.values():
620
+ if runtime.external_session_id == context_session_id:
621
+ return runtime
622
+ for runtime in self._sessions.values():
623
+ if runtime.active_turn_id:
624
+ return runtime
625
+ return None
626
+
627
+ def _load_sdk(self) -> Any:
628
+ if self.sdk_module is not None:
629
+ return self.sdk_module
630
+ try:
631
+ import claude_agent_sdk # type: ignore[import-not-found]
632
+ except ModuleNotFoundError as exc:
633
+ raise ClaudeSdkAdapterError("claude-agent-sdk is not installed") from exc
634
+ return claude_agent_sdk
635
+
636
+ async def _materialize_runtime_content(
637
+ self,
638
+ *,
639
+ content: str,
640
+ attachments: Any,
641
+ cwd: str | None,
642
+ session_id: str,
643
+ ) -> Any:
644
+ if not isinstance(attachments, list) or not attachments:
645
+ return content
646
+ blocks: list[dict[str, Any]] = [{"type": "text", "text": content}]
647
+ downloadable = False
648
+ for attachment in attachments:
649
+ if not isinstance(attachment, dict):
650
+ continue
651
+ path_hint = _optional_string(attachment.get("pathHint") or attachment.get("path"))
652
+ if path_hint:
653
+ blocks.append({"type": "text", "text": f"\n\nAttached file: {path_hint}"})
654
+ continue
655
+ if _attachment_file_id(attachment) is not None:
656
+ downloadable = True
657
+
658
+ if not downloadable:
659
+ return blocks
660
+ if self.attachment_downloader is None:
661
+ logger.warning("dropping {} Claude attachments - no downloader is wired", len(attachments))
662
+ blocks.append(
663
+ {
664
+ "type": "text",
665
+ "text": "\n\n[Attachments could not be loaded: connector downloader unavailable]",
666
+ }
667
+ )
668
+ return blocks
669
+
670
+ for attachment in attachments:
671
+ if not isinstance(attachment, dict):
672
+ continue
673
+ if _optional_string(attachment.get("pathHint") or attachment.get("path")):
674
+ continue
675
+ file_id = _attachment_file_id(attachment)
676
+ if file_id is None:
677
+ continue
678
+ try:
679
+ data, original_name, media_type = await self.attachment_downloader(
680
+ session_id, file_id
681
+ )
682
+ except Exception as exc:
683
+ logger.exception("Claude attachment download failed file_id={}", file_id)
684
+ blocks.append({"type": "text", "text": f"\n\n[Failed to load attachment {file_id}: {exc}]"})
685
+ continue
686
+ original_name = original_name or _attachment_name_from(attachment) or file_id
687
+ media_type = media_type or _optional_string(attachment.get("mediaType")) or "application/octet-stream"
688
+ target = attachment_target(session_id, file_id, original_name)
689
+ target.parent.mkdir(parents=True, exist_ok=True)
690
+ target.write_bytes(data)
691
+ try:
692
+ target.chmod(0o600)
693
+ except OSError:
694
+ pass
695
+ if media_type.startswith("image/"):
696
+ blocks.append(
697
+ {
698
+ "type": "image",
699
+ "source": {
700
+ "type": "base64",
701
+ "media_type": media_type,
702
+ "data": base64.b64encode(data).decode("ascii"),
703
+ },
704
+ }
705
+ )
706
+ blocks.append({"type": "text", "text": f"\n\nAttached image: {original_name} at {target}"})
707
+ else:
708
+ blocks.append(
709
+ {
710
+ "type": "text",
711
+ "text": (
712
+ f"\n\n[Attached file: {original_name} ({media_type},"
713
+ f" {len(data)} bytes) at {target}]"
714
+ ),
715
+ }
716
+ )
717
+ return blocks
718
+
719
+
720
+ async def _prompt_stream(content: Any):
721
+ yield {
722
+ "type": "user",
723
+ "message": {
724
+ "role": "user",
725
+ "content": content,
726
+ },
727
+ }
728
+
729
+
730
+ def _attachments_metadata(params: dict[str, Any]) -> list[dict[str, Any]] | None:
731
+ attachments = params.get("attachments")
732
+ if not isinstance(attachments, list) or not attachments:
733
+ return None
734
+ metadata: list[dict[str, Any]] = []
735
+ for attachment in attachments:
736
+ if not isinstance(attachment, dict):
737
+ continue
738
+ item: dict[str, Any] = {}
739
+ for source_key, target_key in (
740
+ ("fileId", "fileId"),
741
+ ("id", "fileId"),
742
+ ("name", "name"),
743
+ ("mediaType", "mediaType"),
744
+ ("size", "size"),
745
+ ("sha256", "sha256"),
746
+ ):
747
+ value = attachment.get(source_key)
748
+ if value is not None and target_key not in item:
749
+ item[target_key] = value
750
+ if item:
751
+ metadata.append(item)
752
+ return metadata or None
753
+
754
+
755
+ def _pending_client_messages(runtime: _SdkSessionRuntime) -> list[dict[str, Any]]:
756
+ if not runtime.current_client_message_id:
757
+ return []
758
+ message: dict[str, Any] = {"clientMessageId": runtime.current_client_message_id}
759
+ if runtime.current_content is not None:
760
+ message["text"] = runtime.current_content
761
+ if runtime.current_attachments:
762
+ message["attachments"] = runtime.current_attachments
763
+ return [message]
764
+
765
+
766
+ def _record_stderr(runtime: _SdkSessionRuntime, line: str) -> None:
767
+ cleaned = _redact(line.strip())
768
+ if not cleaned:
769
+ return
770
+ runtime.stderr_lines.append(cleaned)
771
+ if len(runtime.stderr_lines) > _MAX_STDERR_LINES:
772
+ del runtime.stderr_lines[: len(runtime.stderr_lines) - _MAX_STDERR_LINES]
773
+ logger.warning("claude sdk stderr session_id={} line={}", runtime.session_id, cleaned)
774
+
775
+
776
+ def _filter_live_task_events(
777
+ runtime: _SdkSessionRuntime,
778
+ events: list[Any],
779
+ ) -> list[Any]:
780
+ out: list[Any] = []
781
+ for event in events:
782
+ tool_use_id = _optional_string(getattr(event, "toolUseId", None))
783
+ if tool_use_id and getattr(event, "toolResult", None) is None and is_task_event_tool_name(getattr(event, "toolName", None)):
784
+ runtime.ignored_task_tool_use_ids.add(tool_use_id)
785
+ continue
786
+ if tool_use_id and getattr(event, "toolResult", None) is not None and tool_use_id in runtime.ignored_task_tool_use_ids:
787
+ continue
788
+ out.append(event)
789
+ return out
790
+
791
+
792
+ def _stderr_excerpt(lines: list[str]) -> str | None:
793
+ if not lines:
794
+ return None
795
+ text = "\n".join(lines[-_MAX_STDERR_LINES:])
796
+ if len(text) > _MAX_STDERR_CHARS:
797
+ return "..." + text[-_MAX_STDERR_CHARS:]
798
+ return text
799
+
800
+
801
+ def _failure_message(exc: Exception, stderr: str | None) -> str:
802
+ message = str(exc)
803
+ if stderr:
804
+ return f"{message}\n\nClaude stderr:\n{stderr}"
805
+ return message
806
+
807
+
808
+ def _redact(value: str) -> str:
809
+ return _SECRET_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}***", value)
810
+
811
+
812
+ async def _maybe_await(method: Any) -> None:
813
+ if not callable(method):
814
+ return
815
+ result = method()
816
+ if hasattr(result, "__await__"):
817
+ await result
818
+
819
+
820
+ def _sdk_message_to_raw(
821
+ message: Any,
822
+ fallback_session_id: str | None,
823
+ ) -> dict[str, Any] | None:
824
+ content = _extract_attr(message, "content")
825
+ role = _message_role(message)
826
+ if content is None and role is None:
827
+ return None
828
+ blocks = _blocks_to_dicts(content)
829
+ session_id = (
830
+ _optional_string(_extract_attr(message, "session_id", "sessionId"))
831
+ or fallback_session_id
832
+ or "unknown"
833
+ )
834
+ message_id = _optional_string(_extract_attr(message, "message_id", "messageId"))
835
+ textless_blocks = _without_text_blocks(blocks)
836
+ if _has_text_blocks(blocks) and message_id is None:
837
+ if textless_blocks:
838
+ blocks = textless_blocks
839
+ else:
840
+ logger.warning(
841
+ "dropping Claude SDK text message without message_id role={} session_id={}",
842
+ role,
843
+ session_id,
844
+ )
845
+ return None
846
+ if not blocks:
847
+ logger.warning(
848
+ "dropping Claude SDK message with no reducible blocks role={} session_id={}",
849
+ role,
850
+ session_id,
851
+ )
852
+ return None
853
+ source_event_id = _optional_string(_extract_attr(message, "uuid")) or message_id or "unknown"
854
+ return {
855
+ "uuid": source_event_id,
856
+ "session_id": session_id,
857
+ "timestamp": _optional_string(_extract_attr(message, "timestamp")) or utc_now(),
858
+ "message": {
859
+ "id": message_id,
860
+ "role": role,
861
+ "content": blocks,
862
+ },
863
+ }
864
+
865
+
866
+ def _result_message_to_raw(message: Any, fallback_session_id: str | None) -> dict[str, Any] | None:
867
+ text = _optional_string(_extract_attr(message, "result"))
868
+ if not text:
869
+ return None
870
+ message_id = _optional_string(_extract_attr(message, "uuid"))
871
+ if message_id is None:
872
+ logger.warning(
873
+ "dropping Claude result text without uuid session_id={}",
874
+ _optional_string(_extract_attr(message, "session_id", "sessionId")) or fallback_session_id,
875
+ )
876
+ return None
877
+ session_id = (
878
+ _optional_string(_extract_attr(message, "session_id", "sessionId"))
879
+ or fallback_session_id
880
+ or "unknown"
881
+ )
882
+ return {
883
+ "uuid": message_id,
884
+ "session_id": session_id,
885
+ "timestamp": utc_now(),
886
+ "message": {
887
+ "id": message_id,
888
+ "role": "assistant",
889
+ "content": [{"type": "text", "text": text}],
890
+ },
891
+ }
892
+
893
+
894
+ def _stream_event_to_raw(runtime: _SdkSessionRuntime, turn_id: str, message: Any) -> dict[str, Any] | None:
895
+ event = _extract_attr(message, "event")
896
+ if not isinstance(event, dict):
897
+ return None
898
+ event_type = _optional_string(event.get("type"))
899
+ if event_type == "message_start":
900
+ payload = event.get("message")
901
+ runtime.partial_text_blocks.clear()
902
+ if isinstance(payload, dict):
903
+ runtime.partial_message_id = _optional_string(payload.get("id"))
904
+ else:
905
+ runtime.partial_message_id = None
906
+ runtime.partial_message_uuid = _optional_string(_extract_attr(message, "uuid"))
907
+ return None
908
+ if event_type == "content_block_start":
909
+ index = _int(event.get("index"))
910
+ block = event.get("content_block")
911
+ text = _text_from_stream_block(block)
912
+ if index is not None and text is not None:
913
+ runtime.partial_text_blocks[index] = text
914
+ return _partial_message_raw(runtime, turn_id, message)
915
+ return None
916
+ if event_type == "content_block_delta":
917
+ index = _int(event.get("index"))
918
+ delta = event.get("delta")
919
+ text = _text_from_stream_block(delta)
920
+ if index is not None and text:
921
+ runtime.partial_text_blocks[index] = f"{runtime.partial_text_blocks.get(index, '')}{text}"
922
+ return _partial_message_raw(runtime, turn_id, message)
923
+ if event_type == "message_delta":
924
+ return _partial_message_raw(runtime, turn_id, message)
925
+ return None
926
+
927
+
928
+ def _partial_message_raw(runtime: _SdkSessionRuntime, turn_id: str, message: Any) -> dict[str, Any] | None:
929
+ text = "".join(runtime.partial_text_blocks[index] for index in sorted(runtime.partial_text_blocks))
930
+ if not text:
931
+ return None
932
+ message_id = runtime.partial_message_id
933
+ if message_id is None:
934
+ logger.warning("dropping Claude stream text without message_start id turn_id={}", turn_id)
935
+ return None
936
+ return {
937
+ "uuid": runtime.partial_message_uuid or message_id,
938
+ "session_id": _optional_string(_extract_attr(message, "session_id", "sessionId")) or runtime.external_session_id or "unknown",
939
+ "timestamp": utc_now(),
940
+ "message": {
941
+ "id": message_id,
942
+ "role": "assistant",
943
+ "content": [{"type": "text", "text": text}],
944
+ },
945
+ }
946
+
947
+
948
+ def _text_from_stream_block(value: Any) -> str | None:
949
+ if not isinstance(value, dict):
950
+ return None
951
+ block_type = _optional_string(value.get("type"))
952
+ if block_type in {"text", "text_delta"}:
953
+ return _optional_string(value.get("text"))
954
+ if block_type == "input_json_delta":
955
+ return None
956
+ return _optional_string(value.get("text"))
957
+
958
+
959
+ def _is_streaming_assistant_message(item: dict[str, Any]) -> bool:
960
+ return (
961
+ item.get("type") == "message"
962
+ and item.get("role") == "assistant"
963
+ and isinstance(item.get("id"), str)
964
+ )
965
+
966
+
967
+ def _is_tool_item(item: dict[str, Any]) -> bool:
968
+ return item.get("type") == "tool" and isinstance(item.get("id"), str)
969
+
970
+
971
+ def _prepare_live_stream_item(
972
+ runtime: _SdkSessionRuntime,
973
+ item: dict[str, Any],
974
+ ) -> dict[str, Any] | None:
975
+ item_id = _optional_string(item.get("id"))
976
+ if item_id is None:
977
+ return item
978
+ existing = runtime.live_stream_items.get(item_id)
979
+ content = item.get("content") if isinstance(item.get("content"), dict) else {}
980
+ content_hash = _hash_content(content)
981
+ now = utc_now()
982
+ if existing is not None and existing.get("contentHash") == content_hash:
983
+ return None
984
+ if existing is None:
985
+ prepared = dict(item)
986
+ prepared["orderSeq"] = _next_order(runtime)
987
+ prepared["revision"] = 1
988
+ prepared["status"] = "running"
989
+ prepared["contentHash"] = content_hash
990
+ prepared["createdAt"] = item.get("createdAt") or now
991
+ prepared["updatedAt"] = item.get("updatedAt") or now
992
+ prepared.pop("completedAt", None)
993
+ else:
994
+ prepared = dict(item)
995
+ prepared["orderSeq"] = existing.get("orderSeq")
996
+ prepared["revision"] = int(existing.get("revision") or 1) + 1
997
+ prepared["status"] = "running"
998
+ prepared["contentHash"] = content_hash
999
+ prepared["createdAt"] = existing.get("createdAt") or item.get("createdAt") or now
1000
+ prepared["updatedAt"] = item.get("updatedAt") or now
1001
+ prepared.pop("completedAt", None)
1002
+ runtime.live_stream_items[item_id] = prepared
1003
+ return prepared
1004
+
1005
+
1006
+ def _prepare_live_stream_final_item(
1007
+ runtime: _SdkSessionRuntime,
1008
+ item: dict[str, Any],
1009
+ ) -> dict[str, Any] | None:
1010
+ item_id = _optional_string(item.get("id"))
1011
+ if item_id is None:
1012
+ return None
1013
+ existing = runtime.live_stream_items.get(item_id)
1014
+ if existing is None:
1015
+ return None
1016
+ content = item.get("content") if isinstance(item.get("content"), dict) else {}
1017
+ content_hash = _hash_content(content)
1018
+ finalized = dict(item)
1019
+ finalized["orderSeq"] = existing.get("orderSeq")
1020
+ finalized["revision"] = int(existing.get("revision") or 1) + (
1021
+ 0 if existing.get("contentHash") == content_hash and existing.get("status") == "done" else 1
1022
+ )
1023
+ finalized["status"] = "done"
1024
+ finalized["contentHash"] = content_hash
1025
+ finalized["createdAt"] = existing.get("createdAt") or item.get("createdAt") or utc_now()
1026
+ finalized["updatedAt"] = item.get("updatedAt") or utc_now()
1027
+ finalized["completedAt"] = finalized["updatedAt"]
1028
+ runtime.live_stream_items[item_id] = finalized
1029
+ return finalized
1030
+
1031
+
1032
+ def _prepare_live_tool_item(
1033
+ runtime: _SdkSessionRuntime,
1034
+ item: dict[str, Any],
1035
+ ) -> dict[str, Any] | None:
1036
+ item_id = _optional_string(item.get("id"))
1037
+ if item_id is None:
1038
+ return item
1039
+ existing = runtime.live_tool_items.get(item_id)
1040
+ incoming_content = item.get("content") if isinstance(item.get("content"), dict) else {}
1041
+ now = utc_now()
1042
+ if existing is None:
1043
+ prepared = dict(item)
1044
+ prepared["orderSeq"] = _next_order(runtime)
1045
+ prepared["revision"] = int(prepared.get("revision") or 1)
1046
+ prepared["contentHash"] = _hash_content(prepared.get("content") if isinstance(prepared.get("content"), dict) else {})
1047
+ prepared["createdAt"] = item.get("createdAt") or now
1048
+ prepared["updatedAt"] = item.get("updatedAt") or now
1049
+ if prepared.get("status") not in {"done", "failed", "interrupted", "cancelled"}:
1050
+ prepared.pop("completedAt", None)
1051
+ runtime.live_tool_items[item_id] = prepared
1052
+ return prepared
1053
+
1054
+ merged_content = dict(existing.get("content") if isinstance(existing.get("content"), dict) else {})
1055
+ merged_content.update(incoming_content)
1056
+ content_hash = _hash_content(merged_content)
1057
+ incoming_status = _optional_string(item.get("status")) or _optional_string(existing.get("status")) or "running"
1058
+ if existing.get("contentHash") == content_hash and existing.get("status") == incoming_status:
1059
+ return None
1060
+ prepared = dict(existing)
1061
+ prepared["content"] = merged_content
1062
+ prepared["status"] = incoming_status
1063
+ prepared["role"] = item.get("role") or existing.get("role")
1064
+ prepared["revision"] = int(existing.get("revision") or 1) + 1
1065
+ prepared["contentHash"] = content_hash
1066
+ prepared["updatedAt"] = item.get("updatedAt") or now
1067
+ if incoming_status in {"done", "failed", "interrupted", "cancelled"}:
1068
+ prepared["completedAt"] = item.get("completedAt") or prepared["updatedAt"]
1069
+ else:
1070
+ prepared.pop("completedAt", None)
1071
+ runtime.live_tool_items[item_id] = prepared
1072
+ return prepared
1073
+
1074
+
1075
+ def _int(value: Any) -> int | None:
1076
+ return value if isinstance(value, int) else None
1077
+
1078
+
1079
+ def _blocks_to_dicts(content: Any) -> list[dict[str, Any]]:
1080
+ if isinstance(content, str):
1081
+ return [{"type": "text", "text": content}]
1082
+ if not isinstance(content, (list, tuple)):
1083
+ return []
1084
+ blocks: list[dict[str, Any]] = []
1085
+ for block in content:
1086
+ block_type = _optional_string(_extract_attr(block, "type"))
1087
+ if block_type is None:
1088
+ block_type = _block_type_from_class(block)
1089
+ if block_type == "text":
1090
+ text = _optional_string(_extract_attr(block, "text"))
1091
+ if text is None or not text.strip():
1092
+ continue
1093
+ blocks.append({"type": "text", "text": text})
1094
+ elif block_type == "tool_use":
1095
+ blocks.append(
1096
+ {
1097
+ "type": "tool_use",
1098
+ "id": _optional_string(_extract_attr(block, "id")) or _stable_message_id(block),
1099
+ "name": _optional_string(_extract_attr(block, "name")) or "unknown",
1100
+ "input": _extract_attr(block, "input") or {},
1101
+ }
1102
+ )
1103
+ elif block_type == "tool_result":
1104
+ blocks.append(
1105
+ {
1106
+ "type": "tool_result",
1107
+ "tool_use_id": _optional_string(_extract_attr(block, "tool_use_id", "toolUseId")) or "",
1108
+ "content": _extract_attr(block, "content"),
1109
+ "is_error": _extract_attr(block, "is_error", "isError"),
1110
+ }
1111
+ )
1112
+ return blocks
1113
+
1114
+
1115
+ def _has_text_blocks(blocks: list[dict[str, Any]]) -> bool:
1116
+ return any(block.get("type") == "text" and isinstance(block.get("text"), str) for block in blocks)
1117
+
1118
+
1119
+ def _without_text_blocks(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
1120
+ return [block for block in blocks if block.get("type") != "text"]
1121
+
1122
+
1123
+ def _role_from_class(value: Any) -> str | None:
1124
+ name = value.__class__.__name__.lower()
1125
+ if "assistant" in name:
1126
+ return "assistant"
1127
+ if "user" in name:
1128
+ return "user"
1129
+ if "system" in name:
1130
+ return "system"
1131
+ return None
1132
+
1133
+
1134
+ def _message_role(message: Any) -> str | None:
1135
+ return _optional_string(_extract_attr(message, "role")) or _role_from_class(message)
1136
+
1137
+
1138
+ def _block_type_from_class(value: Any) -> str:
1139
+ name = value.__class__.__name__.lower()
1140
+ if "tooluse" in name or "tool_use" in name:
1141
+ return "tool_use"
1142
+ if "toolresult" in name or "tool_result" in name:
1143
+ return "tool_result"
1144
+ return "text"
1145
+
1146
+
1147
+ def _is_result_message(message: Any) -> bool:
1148
+ name = message.__class__.__name__.lower()
1149
+ return "result" in name
1150
+
1151
+
1152
+ def _is_stream_event(message: Any) -> bool:
1153
+ return message.__class__.__name__.lower() == "streamevent"
1154
+
1155
+
1156
+ def _permission_allow(sdk: Any, input_data: dict[str, Any]) -> Any:
1157
+ cls = _optional_attr(sdk, "PermissionResultAllow", "types.PermissionResultAllow")
1158
+ if cls is not None:
1159
+ return cls(updated_input=input_data)
1160
+ return {"behavior": "allow", "updatedInput": input_data}
1161
+
1162
+
1163
+ def _permission_deny(sdk: Any, message: str) -> Any:
1164
+ cls = _optional_attr(sdk, "PermissionResultDeny", "types.PermissionResultDeny")
1165
+ if cls is not None:
1166
+ return cls(message=message)
1167
+ return {"behavior": "deny", "message": message}
1168
+
1169
+
1170
+ def _optional_attr(root: Any, *paths: str) -> Any:
1171
+ for path in paths:
1172
+ current = root
1173
+ for part in path.split("."):
1174
+ current = getattr(current, part, None)
1175
+ if current is None:
1176
+ break
1177
+ if current is not None:
1178
+ return current
1179
+ return None
1180
+
1181
+
1182
+ def _extract_attr(value: Any, *names: str) -> Any:
1183
+ for name in names:
1184
+ if isinstance(value, dict) and name in value:
1185
+ return value[name]
1186
+ if hasattr(value, name):
1187
+ return getattr(value, name)
1188
+ return None
1189
+
1190
+
1191
+ def _turn_start_item(runtime: _SdkSessionRuntime, turn_id: str) -> dict[str, Any]:
1192
+ return _timeline_item(
1193
+ id=f"{turn_id}:turn-start",
1194
+ session_id=runtime.session_id,
1195
+ turn_id=turn_id,
1196
+ item_type="turn.start",
1197
+ status="running",
1198
+ role=None,
1199
+ content={},
1200
+ external_session_id=runtime.external_session_id,
1201
+ source_item_type="turn.start",
1202
+ derived_key="turn-start",
1203
+ order_seq=_next_order(runtime),
1204
+ )
1205
+
1206
+
1207
+ def _turn_end_item(
1208
+ runtime: _SdkSessionRuntime,
1209
+ turn_id: str,
1210
+ *,
1211
+ status: str,
1212
+ result: str,
1213
+ stop_reason: str,
1214
+ ) -> dict[str, Any]:
1215
+ return _timeline_item(
1216
+ id=f"{turn_id}:turn-end",
1217
+ session_id=runtime.session_id,
1218
+ turn_id=turn_id,
1219
+ item_type="turn.end",
1220
+ status=status,
1221
+ role=None,
1222
+ content={"stopReason": stop_reason, "result": result},
1223
+ external_session_id=runtime.external_session_id,
1224
+ source_item_type="turn.end",
1225
+ derived_key="turn-end",
1226
+ order_seq=_next_order(runtime),
1227
+ )
1228
+
1229
+
1230
+ def _timeline_item(
1231
+ *,
1232
+ id: str,
1233
+ session_id: str,
1234
+ turn_id: str,
1235
+ item_type: str,
1236
+ status: str,
1237
+ role: str | None,
1238
+ content: dict[str, Any],
1239
+ external_session_id: str | None,
1240
+ source_item_type: str,
1241
+ derived_key: str | None = None,
1242
+ source_extra: dict[str, Any] | None = None,
1243
+ order_seq: int,
1244
+ ) -> dict[str, Any]:
1245
+ now = utc_now()
1246
+ source: dict[str, Any] = {
1247
+ "runtime": "claude",
1248
+ "sessionId": external_session_id,
1249
+ "turnId": turn_id,
1250
+ "itemId": id,
1251
+ "itemType": source_item_type,
1252
+ "event": source_item_type,
1253
+ }
1254
+ if derived_key:
1255
+ source["derivedKey"] = derived_key
1256
+ if source_extra:
1257
+ source.update(source_extra)
1258
+ return {
1259
+ "id": id,
1260
+ "sessionId": session_id,
1261
+ "turnId": turn_id,
1262
+ "type": item_type,
1263
+ "status": status,
1264
+ "role": role,
1265
+ "content": content,
1266
+ "source": source,
1267
+ "orderSeq": order_seq,
1268
+ "revision": 1,
1269
+ "contentHash": _hash_content(content),
1270
+ "createdAt": now,
1271
+ "updatedAt": now,
1272
+ "completedAt": now if status in {"done", "failed", "interrupted", "cancelled"} else None,
1273
+ }
1274
+
1275
+
1276
+ def _next_order(runtime: _SdkSessionRuntime) -> int:
1277
+ order_seq = runtime.next_order_seq
1278
+ runtime.next_order_seq += 1
1279
+ return order_seq
1280
+
1281
+
1282
+ def _approval_payload(
1283
+ *,
1284
+ approval_id: str,
1285
+ runtime: _SdkSessionRuntime,
1286
+ tool_name: str,
1287
+ input_data: dict[str, Any],
1288
+ ) -> dict[str, Any]:
1289
+ kind = _approval_kind(tool_name)
1290
+ return {
1291
+ "id": approval_id,
1292
+ "sessionId": runtime.session_id,
1293
+ "turnId": runtime.active_turn_id,
1294
+ "status": "pending",
1295
+ "kind": kind,
1296
+ "title": f"Claude requests {tool_name}",
1297
+ "description": _approval_description(tool_name, input_data),
1298
+ "payload": {"toolName": tool_name, "input": input_data},
1299
+ "choices": ["approve", "reject"],
1300
+ "source": {
1301
+ "runtime": "claude",
1302
+ "requestId": approval_id,
1303
+ "sessionId": runtime.external_session_id,
1304
+ "turnId": runtime.active_turn_id,
1305
+ "method": "can_use_tool",
1306
+ },
1307
+ }
1308
+
1309
+
1310
+ def _approval_kind(tool_name: str) -> str:
1311
+ if tool_name == "Bash":
1312
+ return "command"
1313
+ if tool_name in {"Edit", "Write", "NotebookEdit"}:
1314
+ return "file_change"
1315
+ return "tool_call"
1316
+
1317
+
1318
+ def _approval_description(tool_name: str, input_data: dict[str, Any]) -> str:
1319
+ if tool_name == "Bash":
1320
+ return _optional_string(input_data.get("command")) or "Run command"
1321
+ if tool_name in {"Edit", "Write", "NotebookEdit"}:
1322
+ return _optional_string(input_data.get("file_path")) or "Modify file"
1323
+ return json.dumps(input_data, ensure_ascii=False, sort_keys=True)
1324
+
1325
+
1326
+ def _approval_id(session_id: str, turn_id: str | None, tool_name: str, input_data: dict[str, Any]) -> str:
1327
+ return "appr_" + _short_hash([session_id, turn_id, tool_name, input_data])
1328
+
1329
+
1330
+ def _turn_id(session_id: str, content: str) -> str:
1331
+ return "turn_claude_" + _short_hash([session_id, content, secrets.token_urlsafe(8)])
1332
+
1333
+
1334
+ def _stable_message_id(value: Any) -> str:
1335
+ return "msg_" + _short_hash(repr(value))
1336
+
1337
+
1338
+ def _hash_content(content: Any) -> str:
1339
+ return "sha256:" + hashlib.sha256(
1340
+ json.dumps(content, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
1341
+ ).hexdigest()
1342
+
1343
+
1344
+ def _short_hash(value: Any) -> str:
1345
+ return hashlib.sha256(
1346
+ json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
1347
+ ).hexdigest()[:24]
1348
+
1349
+
1350
+ def _required(params: dict[str, Any], key: str) -> str:
1351
+ value = params.get(key)
1352
+ if not isinstance(value, str) or not value:
1353
+ raise ValueError(f"{key} is required")
1354
+ return value
1355
+
1356
+
1357
+ def _optional_string(value: Any) -> str | None:
1358
+ return value if isinstance(value, str) and value else None
1359
+
1360
+
1361
+ def _attachment_file_id(att: Any) -> str | None:
1362
+ if isinstance(att, dict):
1363
+ candidate = att.get("fileId")
1364
+ if isinstance(candidate, str) and candidate:
1365
+ return candidate
1366
+ return None
1367
+
1368
+
1369
+ def _attachment_name_from(att: Any) -> str | None:
1370
+ if isinstance(att, dict):
1371
+ candidate = att.get("name")
1372
+ if isinstance(candidate, str) and candidate:
1373
+ return candidate
1374
+ return None
1375
+
1376
+
1377
+ __all__ = ["ClaudeSdkAdapter", "ClaudeSdkAdapterError"]