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