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.
- anywhere_cli-0.1.0.dist-info/METADATA +110 -0
- anywhere_cli-0.1.0.dist-info/RECORD +36 -0
- anywhere_cli-0.1.0.dist-info/WHEEL +4 -0
- anywhere_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +334 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1377 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +149 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +951 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1223 -0
- connector/codex/rpc.py +260 -0
- connector/launch.py +104 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +122 -0
- connector/local/ops.py +83 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +389 -0
- connector/local_ops.py +5 -0
- connector/protocol.py +26 -0
- connector/runtime.py +1002 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
|
@@ -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"]
|