devcopilot 0.2.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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
messaging/commands.py ADDED
@@ -0,0 +1,275 @@
1
+ """Command handlers for messaging platform commands (/stop, /stats, /clear).
2
+
3
+ Commands depend on MessagingCommandContext instead of the concrete workflow.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from loguru import logger
11
+
12
+ from .command_context import MessagingCommandContext
13
+
14
+ if TYPE_CHECKING:
15
+ from messaging.models import IncomingMessage
16
+
17
+
18
+ async def handle_stop_command(
19
+ handler: MessagingCommandContext, incoming: IncomingMessage
20
+ ) -> None:
21
+ """Handle /stop command from messaging platform."""
22
+ # Reply-scoped stop: reply "/stop" to stop only that task.
23
+ if incoming.is_reply() and incoming.reply_to_message_id:
24
+ reply_id = incoming.reply_to_message_id
25
+ tree = handler.tree_queue.get_tree_for_node(reply_id)
26
+ node_id = handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
27
+
28
+ if not node_id:
29
+ msg_id = await handler.platform.queue_send_message(
30
+ incoming.chat_id,
31
+ handler.format_status(
32
+ "⏹", "Stopped.", "Nothing to stop for that message."
33
+ ),
34
+ fire_and_forget=False,
35
+ message_thread_id=incoming.message_thread_id,
36
+ )
37
+ handler.record_outgoing_message(
38
+ incoming.platform, incoming.chat_id, msg_id, "command"
39
+ )
40
+ return
41
+
42
+ count = await handler.stop_task(node_id)
43
+ noun = "request" if count == 1 else "requests"
44
+ msg_id = await handler.platform.queue_send_message(
45
+ incoming.chat_id,
46
+ handler.format_status("⏹", "Stopped.", f"Cancelled {count} {noun}."),
47
+ fire_and_forget=False,
48
+ message_thread_id=incoming.message_thread_id,
49
+ )
50
+ handler.record_outgoing_message(
51
+ incoming.platform, incoming.chat_id, msg_id, "command"
52
+ )
53
+ return
54
+
55
+ # Global stop: legacy behavior (stop everything)
56
+ count = await handler.stop_all_tasks()
57
+ msg_id = await handler.platform.queue_send_message(
58
+ incoming.chat_id,
59
+ handler.format_status(
60
+ "⏹", "Stopped.", f"Cancelled {count} pending or active requests."
61
+ ),
62
+ fire_and_forget=False,
63
+ message_thread_id=incoming.message_thread_id,
64
+ )
65
+ handler.record_outgoing_message(
66
+ incoming.platform, incoming.chat_id, msg_id, "command"
67
+ )
68
+
69
+
70
+ async def handle_stats_command(
71
+ handler: MessagingCommandContext, incoming: IncomingMessage
72
+ ) -> None:
73
+ """Handle /stats command."""
74
+ stats = handler.cli_manager.get_stats()
75
+ tree_count = handler.tree_queue.get_tree_count()
76
+ ctx = handler.get_render_ctx()
77
+ msg_id = await handler.platform.queue_send_message(
78
+ incoming.chat_id,
79
+ "📊 "
80
+ + ctx.bold("Stats")
81
+ + "\n"
82
+ + ctx.escape_text(f"• Active CLI: {stats['active_sessions']}")
83
+ + "\n"
84
+ + ctx.escape_text(f"• Message Trees: {tree_count}"),
85
+ fire_and_forget=False,
86
+ message_thread_id=incoming.message_thread_id,
87
+ )
88
+ handler.record_outgoing_message(
89
+ incoming.platform, incoming.chat_id, msg_id, "command"
90
+ )
91
+
92
+
93
+ async def _delete_message_ids(
94
+ handler: MessagingCommandContext, chat_id: str, msg_ids: set[str]
95
+ ) -> None:
96
+ """Best-effort delete messages by ID. Sorts numeric IDs descending."""
97
+ if not msg_ids:
98
+ return
99
+
100
+ def _as_int(s: str) -> int | None:
101
+ try:
102
+ return int(str(s))
103
+ except Exception:
104
+ return None
105
+
106
+ numeric: list[tuple[int, str]] = []
107
+ non_numeric: list[str] = []
108
+ for mid in msg_ids:
109
+ n = _as_int(mid)
110
+ if n is None:
111
+ non_numeric.append(mid)
112
+ else:
113
+ numeric.append((n, mid))
114
+ numeric.sort(reverse=True)
115
+ ordered = [mid for _, mid in numeric] + non_numeric
116
+
117
+ try:
118
+ CHUNK = 100
119
+ for i in range(0, len(ordered), CHUNK):
120
+ chunk = ordered[i : i + CHUNK]
121
+ await handler.platform.queue_delete_messages(
122
+ chat_id, chunk, fire_and_forget=False
123
+ )
124
+ except Exception as e:
125
+ logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
126
+
127
+
128
+ async def _handle_clear_branch(
129
+ handler: MessagingCommandContext,
130
+ incoming: IncomingMessage,
131
+ branch_root_id: str,
132
+ ) -> None:
133
+ """
134
+ Clear a branch (replied-to node + all descendants).
135
+
136
+ Order: cancel tasks, delete messages, remove branch, update session store.
137
+ """
138
+ tree = handler.tree_queue.get_tree_for_node(branch_root_id)
139
+ if not tree:
140
+ return
141
+
142
+ # 1) Cancel branch tasks (no stop_all)
143
+ cancelled = await handler.tree_queue.cancel_branch(branch_root_id)
144
+ handler.update_cancelled_nodes_ui(cancelled)
145
+
146
+ # 2) Collect message IDs from branch nodes only
147
+ msg_ids: set[str] = set()
148
+ branch_ids = tree.get_descendants(branch_root_id)
149
+ for nid in branch_ids:
150
+ node = tree.get_node(nid)
151
+ if node:
152
+ if node.incoming.message_id:
153
+ msg_ids.add(str(node.incoming.message_id))
154
+ if node.status_message_id:
155
+ msg_ids.add(str(node.status_message_id))
156
+ if incoming.message_id:
157
+ msg_ids.add(str(incoming.message_id))
158
+
159
+ # 3) Delete messages (best-effort)
160
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids)
161
+
162
+ # 4) Remove branch from tree
163
+ removed, root_id, removed_entire_tree = await handler.tree_queue.remove_branch(
164
+ branch_root_id
165
+ )
166
+
167
+ # 5) Update session store
168
+ try:
169
+ handler.session_store.remove_node_mappings([n.node_id for n in removed])
170
+ if removed_entire_tree:
171
+ handler.session_store.remove_tree(root_id)
172
+ else:
173
+ updated_tree = handler.tree_queue.get_tree(root_id)
174
+ if updated_tree:
175
+ handler.session_store.save_tree(root_id, updated_tree.to_dict())
176
+ except Exception as e:
177
+ logger.warning(f"Failed to update session store after branch clear: {e}")
178
+
179
+
180
+ async def handle_clear_command(
181
+ handler: MessagingCommandContext, incoming: IncomingMessage
182
+ ) -> None:
183
+ """
184
+ Handle /clear command.
185
+
186
+ Reply-scoped: reply to a message to clear that branch (node + descendants).
187
+ Standalone: global clear (stop all, delete all chat messages, reset store).
188
+ """
189
+ from messaging.trees import TreeQueueManager
190
+
191
+ if incoming.is_reply() and incoming.reply_to_message_id:
192
+ reply_id = incoming.reply_to_message_id
193
+ tree = handler.tree_queue.get_tree_for_node(reply_id)
194
+ branch_root_id = (
195
+ handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
196
+ )
197
+ if not branch_root_id:
198
+ cancel_fn = getattr(handler.platform, "cancel_pending_voice", None)
199
+ if cancel_fn is not None:
200
+ cancelled = await cancel_fn(incoming.chat_id, reply_id)
201
+ if cancelled is not None:
202
+ voice_msg_id, status_msg_id = cancelled
203
+ msg_ids_to_del: set[str] = {voice_msg_id, status_msg_id}
204
+ if incoming.message_id is not None:
205
+ msg_ids_to_del.add(str(incoming.message_id))
206
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids_to_del)
207
+ msg_id = await handler.platform.queue_send_message(
208
+ incoming.chat_id,
209
+ handler.format_status("🗑", "Cleared.", "Voice note cancelled."),
210
+ fire_and_forget=False,
211
+ message_thread_id=incoming.message_thread_id,
212
+ )
213
+ handler.record_outgoing_message(
214
+ incoming.platform, incoming.chat_id, msg_id, "command"
215
+ )
216
+ return
217
+ msg_id = await handler.platform.queue_send_message(
218
+ incoming.chat_id,
219
+ handler.format_status(
220
+ "🗑", "Cleared.", "Nothing to clear for that message."
221
+ ),
222
+ fire_and_forget=False,
223
+ message_thread_id=incoming.message_thread_id,
224
+ )
225
+ handler.record_outgoing_message(
226
+ incoming.platform, incoming.chat_id, msg_id, "command"
227
+ )
228
+ return
229
+ await _handle_clear_branch(handler, incoming, branch_root_id)
230
+ return
231
+
232
+ # Global clear
233
+ # 1) Stop tasks first (ensures no more work is running).
234
+ await handler.stop_all_tasks()
235
+
236
+ # 2) Clear chat: best-effort delete messages we can identify.
237
+ msg_ids: set[str] = set()
238
+
239
+ # Add any recorded message IDs for this chat (commands, command replies, etc).
240
+ try:
241
+ for mid in handler.session_store.get_message_ids_for_chat(
242
+ incoming.platform, incoming.chat_id
243
+ ):
244
+ if mid is not None:
245
+ msg_ids.add(str(mid))
246
+ except Exception as e:
247
+ logger.debug(f"Failed to read message log for /clear: {e}")
248
+
249
+ try:
250
+ msg_ids.update(
251
+ handler.tree_queue.get_message_ids_for_chat(
252
+ incoming.platform, incoming.chat_id
253
+ )
254
+ )
255
+ except Exception as e:
256
+ logger.warning(f"Failed to gather messages for /clear: {e}")
257
+
258
+ # Also delete the command message itself.
259
+ if incoming.message_id is not None:
260
+ msg_ids.add(str(incoming.message_id))
261
+
262
+ await _delete_message_ids(handler, incoming.chat_id, msg_ids)
263
+
264
+ # 3) Clear persistent state and reset in-memory queue/tree state.
265
+ try:
266
+ handler.session_store.clear_all()
267
+ except Exception as e:
268
+ logger.warning(f"Failed to clear session store: {e}")
269
+
270
+ handler.replace_tree_queue(
271
+ TreeQueueManager(
272
+ queue_update_callback=handler.update_queue_positions,
273
+ node_started_callback=handler.mark_node_processing,
274
+ )
275
+ )
@@ -0,0 +1,181 @@
1
+ """CLI event parser for Claude Code CLI output.
2
+
3
+ This parser emits an ordered stream of low-level events suitable for building a
4
+ Claude Code-like transcript in messaging UIs.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+
12
+ def parse_cli_event(event: Any, *, log_raw_cli: bool = False) -> list[dict]:
13
+ """
14
+ Parse a CLI event and return a structured result.
15
+
16
+ Args:
17
+ event: Raw event dictionary from CLI
18
+ log_raw_cli: When True, log full error text from the CLI. Default is
19
+ metadata-only (lengths / exit codes) to avoid leaking user content.
20
+
21
+ Returns:
22
+ List of parsed event dicts. Empty list if not recognized.
23
+ """
24
+ if not isinstance(event, dict):
25
+ return []
26
+
27
+ etype = event.get("type")
28
+ results: list[dict[str, Any]] = []
29
+
30
+ # Some CLI/proxy layers emit "system" events that are not user-visible and
31
+ # carry no transcript content. Ignore them explicitly to avoid noisy logs.
32
+ if etype == "system":
33
+ return []
34
+
35
+ # 1. Handle full messages (assistant/user or result)
36
+ msg_obj = None
37
+ if etype == "assistant" or etype == "user":
38
+ msg_obj = event.get("message")
39
+ elif etype == "result":
40
+ res = event.get("result")
41
+ if isinstance(res, dict):
42
+ msg_obj = res.get("message")
43
+ # Some variants put content directly on the result.
44
+ if not msg_obj and isinstance(res.get("content"), list):
45
+ msg_obj = {"content": res.get("content")}
46
+ if not msg_obj:
47
+ msg_obj = event.get("message")
48
+ # Some variants put content directly on the event.
49
+ if not msg_obj and isinstance(event.get("content"), list):
50
+ msg_obj = {"content": event.get("content")}
51
+
52
+ if msg_obj and isinstance(msg_obj, dict):
53
+ content = msg_obj.get("content", [])
54
+ if isinstance(content, list):
55
+ # Preserve order exactly as content blocks appear.
56
+ for c in content:
57
+ if not isinstance(c, dict):
58
+ continue
59
+ ctype = c.get("type")
60
+ if ctype == "text":
61
+ results.append({"type": "text_chunk", "text": c.get("text", "")})
62
+ elif ctype == "thinking":
63
+ results.append(
64
+ {"type": "thinking_chunk", "text": c.get("thinking", "")}
65
+ )
66
+ elif ctype == "tool_use":
67
+ results.append(
68
+ {
69
+ "type": "tool_use",
70
+ "id": str(c.get("id", "") or "").strip(),
71
+ "name": c.get("name", ""),
72
+ "input": c.get("input"),
73
+ }
74
+ )
75
+ elif ctype == "tool_result":
76
+ results.append(
77
+ {
78
+ "type": "tool_result",
79
+ "tool_use_id": str(c.get("tool_use_id", "") or "").strip(),
80
+ "content": c.get("content"),
81
+ "is_error": bool(c.get("is_error", False)),
82
+ }
83
+ )
84
+
85
+ if results:
86
+ return results
87
+
88
+ # 2. Handle streaming deltas
89
+ if etype == "content_block_delta":
90
+ delta = event.get("delta", {})
91
+ if isinstance(delta, dict):
92
+ if delta.get("type") == "text_delta":
93
+ return [
94
+ {
95
+ "type": "text_delta",
96
+ "index": event.get("index", -1),
97
+ "text": delta.get("text", ""),
98
+ }
99
+ ]
100
+ if delta.get("type") == "thinking_delta":
101
+ return [
102
+ {
103
+ "type": "thinking_delta",
104
+ "index": event.get("index", -1),
105
+ "text": delta.get("thinking", ""),
106
+ }
107
+ ]
108
+ if delta.get("type") == "input_json_delta":
109
+ return [
110
+ {
111
+ "type": "tool_use_delta",
112
+ "index": event.get("index", -1),
113
+ "partial_json": delta.get("partial_json", ""),
114
+ }
115
+ ]
116
+
117
+ # 3. Handle tool usage start
118
+ if etype == "content_block_start":
119
+ block = event.get("content_block", {})
120
+ if isinstance(block, dict):
121
+ btype = block.get("type")
122
+ if btype == "thinking":
123
+ return [{"type": "thinking_start", "index": event.get("index", -1)}]
124
+ if btype == "text":
125
+ return [{"type": "text_start", "index": event.get("index", -1)}]
126
+ if btype == "tool_use":
127
+ return [
128
+ {
129
+ "type": "tool_use_start",
130
+ "index": event.get("index", -1),
131
+ "id": str(block.get("id", "") or "").strip(),
132
+ "name": block.get("name", ""),
133
+ "input": block.get("input"),
134
+ }
135
+ ]
136
+
137
+ # 3.5 Handle block stop (to close open streaming segments)
138
+ if etype == "content_block_stop":
139
+ return [{"type": "block_stop", "index": event.get("index", -1)}]
140
+
141
+ # 4. Handle errors and exit
142
+ if etype == "error":
143
+ err = event.get("error")
144
+ msg = err.get("message") if isinstance(err, dict) else str(err)
145
+ if log_raw_cli:
146
+ logger.info("CLI_PARSER: Parsed error event: {}", msg)
147
+ else:
148
+ mlen = len(msg) if isinstance(msg, str) else 0
149
+ logger.info("CLI_PARSER: Parsed error event: message_chars={}", mlen)
150
+ return [{"type": "error", "message": msg}]
151
+ elif etype == "exit":
152
+ code = event.get("code", 0)
153
+ stderr = event.get("stderr")
154
+ if code == 0:
155
+ logger.debug(f"CLI_PARSER: Successful exit (code={code})")
156
+ return [{"type": "complete", "status": "success"}]
157
+ else:
158
+ # Non-zero exit is an error
159
+ error_msg = stderr if stderr else f"Process exited with code {code}"
160
+ if log_raw_cli:
161
+ logger.warning(
162
+ "CLI_PARSER: Error exit (code={}): {}",
163
+ code,
164
+ error_msg,
165
+ )
166
+ else:
167
+ em = error_msg if isinstance(error_msg, str) else str(error_msg)
168
+ logger.warning(
169
+ "CLI_PARSER: Error exit (code={}): message_chars={}",
170
+ code,
171
+ len(em),
172
+ )
173
+ return [
174
+ {"type": "error", "message": error_msg},
175
+ {"type": "complete", "status": "failed"},
176
+ ]
177
+
178
+ # Log unrecognized events for debugging
179
+ if etype:
180
+ logger.debug(f"CLI_PARSER: Unrecognized event type: {etype}")
181
+ return []