agentpool 2.1.9__py3-none-any.whl → 2.5.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 (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,39 @@ The ClaudeCodeAgent acts as a client to the Claude Code CLI, enabling:
8
8
  - Tool permission handling via callbacks
9
9
  - Integration with agentpool's event system
10
10
 
11
+ Tool Call Event Flow
12
+ --------------------
13
+ The SDK streams events in a specific order. Understanding this is critical for
14
+ avoiding race conditions with permission dialogs:
15
+
16
+ 1. **content_block_start** (StreamEvent)
17
+ - Contains tool_use_id, tool name
18
+ - We emit ToolCallStartEvent here (early, with empty args)
19
+ - ACP converter sends `tool_call` notification to client
20
+
21
+ 2. **content_block_delta** (StreamEvent, multiple)
22
+ - Contains input_json_delta with partial JSON args
23
+ - We emit PartDeltaEvent(ToolCallPartDelta) for streaming
24
+ - ACP converter accumulates args, doesn't send notifications
25
+
26
+ 3. **AssistantMessage** with ToolUseBlock
27
+ - Contains complete tool call info (id, name, full args)
28
+ - We do NOT emit events here (would race with permission)
29
+ - Just track file modifications silently
30
+
31
+ 4. **content_block_stop**, **message_delta**, **message_stop** (StreamEvent)
32
+ - Signal completion of the message
33
+
34
+ 5. **can_use_tool callback** (~100ms after message_stop)
35
+ - SDK calls our permission callback
36
+ - We send permission request to ACP client
37
+ - Client shows permission dialog to user
38
+ - IMPORTANT: No notifications should be sent while dialog is open!
39
+
40
+ 6. **Tool execution or denial**
41
+ - If allowed: tool runs, emits ToolCallCompleteEvent
42
+ - If denied: SDK receives denial, continues with next turn
43
+
11
44
  Example:
12
45
  ```python
13
46
  async with ClaudeCodeAgent(
@@ -23,41 +56,46 @@ Example:
23
56
  from __future__ import annotations
24
57
 
25
58
  import asyncio
59
+ import contextlib
26
60
  from decimal import Decimal
27
- from typing import TYPE_CHECKING, Any, Self, cast
61
+ import re
62
+ from typing import TYPE_CHECKING, Any, Literal, Self
28
63
  import uuid
29
64
 
30
65
  import anyio
31
- from pydantic import TypeAdapter
32
66
  from pydantic_ai import (
67
+ FunctionToolResultEvent,
33
68
  ModelRequest,
34
69
  ModelResponse,
35
70
  PartDeltaEvent,
36
71
  PartEndEvent,
37
- PartStartEvent,
38
72
  RunUsage,
39
73
  TextPart,
40
74
  TextPartDelta,
41
75
  ThinkingPart,
42
76
  ThinkingPartDelta,
43
77
  ToolCallPart,
78
+ ToolCallPartDelta,
44
79
  ToolReturnPart,
45
80
  UserPromptPart,
46
81
  )
82
+ from pydantic_ai.usage import RequestUsage
47
83
 
48
84
  from agentpool.agents.base_agent import BaseAgent
49
85
  from agentpool.agents.claude_code_agent.converters import claude_message_to_events
50
86
  from agentpool.agents.events import (
87
+ PartStartEvent,
51
88
  RunErrorEvent,
52
89
  RunStartedEvent,
53
90
  StreamCompleteEvent,
54
91
  ToolCallCompleteEvent,
55
92
  ToolCallStartEvent,
56
93
  )
94
+ from agentpool.agents.events.processors import FileTracker
95
+ from agentpool.agents.modes import ModeInfo
57
96
  from agentpool.log import get_logger
58
97
  from agentpool.messaging import ChatMessage
59
98
  from agentpool.messaging.messages import TokenCost
60
- from agentpool.messaging.processing import prepare_prompts
61
99
  from agentpool.models.claude_code_agents import ClaudeCodeAgentConfig
62
100
  from agentpool.utils.streams import merge_queue_into_iterator
63
101
 
@@ -75,21 +113,31 @@ if TYPE_CHECKING:
75
113
  ToolPermissionContext,
76
114
  ToolUseBlock,
77
115
  )
78
- from evented.configs import EventConfig
116
+ from claude_agent_sdk.types import HookContext, HookInput, SyncHookJSONOutput
117
+ from evented_config import EventConfig
79
118
  from exxec import ExecutionEnvironment
119
+ from pydantic_ai import UserContent
120
+ from slashed import BaseCommand, Command, CommandContext
121
+ from tokonomics.model_discovery.model_info import ModelInfo
122
+ from tokonomics.model_names import AnthropicMaxModelName
80
123
  from toprompt import AnyPromptType
81
124
 
125
+ from agentpool.agents.claude_code_agent.models import (
126
+ ClaudeCodeCommandInfo,
127
+ ClaudeCodeServerInfo,
128
+ )
82
129
  from agentpool.agents.context import AgentContext
83
130
  from agentpool.agents.events import RichAgentStreamEvent
131
+ from agentpool.agents.modes import ModeCategory
84
132
  from agentpool.common_types import (
85
133
  BuiltinEventHandlerType,
86
134
  IndividualEventHandler,
87
- PromptCompatible,
135
+ MCPServerStatus,
88
136
  )
89
137
  from agentpool.delegation import AgentPool
90
138
  from agentpool.mcp_server.tool_bridge import ToolManagerBridge
91
139
  from agentpool.messaging import MessageHistory
92
- from agentpool.talk.stats import MessageStats
140
+ from agentpool.models.claude_code_agents import SettingSource
93
141
  from agentpool.ui.base import InputProvider
94
142
  from agentpool_config.mcp_server import MCPServerConfig
95
143
  from agentpool_config.nodes import ToolConfirmationMode
@@ -97,6 +145,30 @@ if TYPE_CHECKING:
97
145
 
98
146
  logger = get_logger(__name__)
99
147
 
148
+ # Pattern to strip MCP server prefix from tool names
149
+ # Format: mcp__agentpool-{agent_name}-tools__{tool_name}
150
+ _MCP_TOOL_PATTERN = re.compile(r"^mcp__agentpool-(.+)-tools__(.+)$")
151
+
152
+ # Thinking modes for extended thinking budget allocation
153
+ ThinkingMode = Literal["off", "on"]
154
+
155
+ # Map thinking mode to prompt instruction
156
+ # "ultrathink" triggers ~32k token thinking budget in Claude Code
157
+ THINKING_MODE_PROMPTS: dict[ThinkingMode, str] = {
158
+ "off": "",
159
+ "on": "ultrathink",
160
+ }
161
+
162
+
163
+ def _strip_mcp_prefix(tool_name: str) -> str:
164
+ """Strip MCP server prefix from tool names for cleaner UI display.
165
+
166
+ Handles dynamic prefixes like mcp__agentpool-{agent_name}-tools__{tool}
167
+ """
168
+ if match := _MCP_TOOL_PATTERN.match(tool_name):
169
+ return match.group(2) # group(1) is agent name, group(2) is tool name
170
+ return tool_name
171
+
100
172
 
101
173
  class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
102
174
  """Agent wrapping Claude Agent SDK's ClaudeSDKClient.
@@ -124,16 +196,19 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
124
196
  disallowed_tools: list[str] | None = None,
125
197
  system_prompt: str | Sequence[str] | None = None,
126
198
  include_builtin_system_prompt: bool = True,
127
- model: str | None = None,
199
+ model: AnthropicMaxModelName | str | None = None,
128
200
  max_turns: int | None = None,
201
+ max_budget_usd: float | None = None,
129
202
  max_thinking_tokens: int | None = None,
130
203
  permission_mode: PermissionMode | None = None,
131
204
  mcp_servers: Sequence[MCPServerConfig] | None = None,
132
205
  environment: dict[str, str] | None = None,
133
206
  add_dir: list[str] | None = None,
134
207
  builtin_tools: list[str] | None = None,
135
- fallback_model: str | None = None,
208
+ fallback_model: AnthropicMaxModelName | str | None = None,
136
209
  dangerously_skip_permissions: bool = False,
210
+ setting_sources: list[SettingSource] | None = None,
211
+ use_subscription: bool = False,
137
212
  env: ExecutionEnvironment | None = None,
138
213
  input_provider: InputProvider | None = None,
139
214
  agent_pool: AgentPool[Any] | None = None,
@@ -142,6 +217,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
142
217
  event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
143
218
  tool_confirmation_mode: ToolConfirmationMode = "always",
144
219
  output_type: type[TResult] | None = None,
220
+ commands: Sequence[BaseCommand] | None = None,
145
221
  ) -> None:
146
222
  """Initialize ClaudeCodeAgent.
147
223
 
@@ -157,14 +233,18 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
157
233
  include_builtin_system_prompt: If True, the builtin system prompt is included.
158
234
  model: Model to use (e.g., "claude-sonnet-4-5")
159
235
  max_turns: Maximum conversation turns
236
+ max_budget_usd: Maximum budget to consume in dollars
160
237
  max_thinking_tokens: Max tokens for extended thinking
161
238
  permission_mode: Permission mode ("default", "acceptEdits", "plan", "bypassPermissions")
162
239
  mcp_servers: External MCP servers to connect to (internal format, converted at runtime)
163
240
  environment: Environment variables for the agent process
164
241
  add_dir: Additional directories to allow tool access to
165
- builtin_tools: Available tools from Claude Code's built-in set (empty list disables all)
242
+ builtin_tools: Available tools from built-in set. Special: "LSP" for code intelligence,
243
+ "Chrome" for browser control
166
244
  fallback_model: Fallback model when default is overloaded
167
245
  dangerously_skip_permissions: Bypass all permission checks (sandboxed only)
246
+ setting_sources: Setting sources to load ("user", "project", "local")
247
+ use_subscription: Force Claude subscription usage instead of API key
168
248
  env: Execution environment
169
249
  input_provider: Provider for user input/confirmations
170
250
  agent_pool: Agent pool for multi-agent coordination
@@ -173,6 +253,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
173
253
  event_handlers: Event handlers for streaming events
174
254
  tool_confirmation_mode: Tool confirmation behavior
175
255
  output_type: Type for structured output (uses JSON schema)
256
+ commands: Slash commands
176
257
  """
177
258
  from agentpool.agents.sys_prompts import SystemPrompts
178
259
 
@@ -197,6 +278,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
197
278
  builtin_tools=builtin_tools,
198
279
  fallback_model=fallback_model,
199
280
  dangerously_skip_permissions=dangerously_skip_permissions,
281
+ setting_sources=setting_sources,
282
+ use_subscription=use_subscription,
200
283
  )
201
284
 
202
285
  super().__init__(
@@ -211,6 +294,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
211
294
  output_type=output_type or str, # type: ignore[arg-type]
212
295
  tool_confirmation_mode=tool_confirmation_mode,
213
296
  event_handlers=event_handlers,
297
+ commands=commands,
214
298
  )
215
299
 
216
300
  self._config = config
@@ -234,8 +318,10 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
234
318
  self.sys_prompts = SystemPrompts(all_prompts, prompt_manager=prompt_manager)
235
319
  self._model = model or config.model
236
320
  self._max_turns = max_turns or config.max_turns
321
+ self._max_budget_usd = max_budget_usd or config.max_budget_usd
237
322
  self._max_thinking_tokens = max_thinking_tokens or config.max_thinking_tokens
238
323
  self._permission_mode: PermissionMode | None = permission_mode or config.permission_mode
324
+ self._thinking_mode: ThinkingMode = "off"
239
325
  self._external_mcp_servers = list(mcp_servers) if mcp_servers else config.get_mcp_servers()
240
326
  self._environment = environment or config.env
241
327
  self._add_dir = add_dir or config.add_dir
@@ -244,10 +330,14 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
244
330
  self._dangerously_skip_permissions = (
245
331
  dangerously_skip_permissions or config.dangerously_skip_permissions
246
332
  )
333
+ self._setting_sources = setting_sources or config.setting_sources
334
+ self._use_subscription = use_subscription or config.use_subscription
247
335
 
248
336
  # Client state
249
337
  self._client: ClaudeSDKClient | None = None
250
- self._current_model: str | None = self._model
338
+ self._connection_task: asyncio.Task[None] | None = None
339
+ self._current_model: AnthropicMaxModelName | str | None = self._model
340
+ self._sdk_session_id: str | None = None # Session ID from Claude SDK init message
251
341
  self.deps_type = type(None)
252
342
 
253
343
  # ToolBridge state for exposing toolsets via MCP
@@ -255,6 +345,49 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
255
345
  self._owns_bridge = False # Track if we created the bridge (for cleanup)
256
346
  self._mcp_servers: dict[str, McpServerConfig] = {} # Claude SDK MCP server configs
257
347
 
348
+ # Track pending tool call for permission matching
349
+ # Maps tool_name to tool_call_id for matching permissions to tool call UI parts
350
+ self._pending_tool_call_ids: dict[str, str] = {}
351
+
352
+ @classmethod
353
+ def from_config(
354
+ cls,
355
+ config: ClaudeCodeAgentConfig,
356
+ *,
357
+ event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
358
+ input_provider: InputProvider | None = None,
359
+ agent_pool: AgentPool[Any] | None = None,
360
+ output_type: type[TResult] | None = None,
361
+ ) -> Self:
362
+ """Create a ClaudeCodeAgent from a config object.
363
+
364
+ This is the preferred way to instantiate a ClaudeCodeAgent from configuration.
365
+
366
+ Args:
367
+ config: Claude Code agent configuration
368
+ event_handlers: Optional event handlers (merged with config handlers)
369
+ input_provider: Optional input provider for user interactions
370
+ agent_pool: Optional agent pool for coordination
371
+ output_type: Optional output type for structured output
372
+
373
+ Returns:
374
+ Configured ClaudeCodeAgent instance
375
+ """
376
+ # Merge config-level handlers with provided handlers
377
+ config_handlers = config.get_event_handlers()
378
+ merged_handlers: list[IndividualEventHandler | BuiltinEventHandlerType] = [
379
+ *config_handlers,
380
+ *(event_handlers or []),
381
+ ]
382
+ return cls(
383
+ config=config,
384
+ event_handlers=merged_handlers or None,
385
+ input_provider=input_provider,
386
+ agent_pool=agent_pool,
387
+ tool_confirmation_mode=config.requires_tool_confirmation,
388
+ output_type=output_type,
389
+ )
390
+
258
391
  def get_context(self, data: Any = None) -> AgentContext:
259
392
  """Create a new context for this agent.
260
393
 
@@ -269,60 +402,14 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
269
402
 
270
403
  defn = self.agent_pool.manifest if self.agent_pool else AgentsManifest()
271
404
  return AgentContext(
272
- node=self, pool=self.agent_pool, config=self._config, definition=defn, data=data
273
- )
274
-
275
- def _convert_mcp_servers_to_sdk_format(self) -> dict[str, McpServerConfig]:
276
- """Convert internal MCPServerConfig to Claude SDK format.
277
-
278
- Returns:
279
- Dict mapping server names to SDK-compatible config dicts
280
- """
281
- from claude_agent_sdk import McpServerConfig
282
-
283
- from agentpool_config.mcp_server import (
284
- SSEMCPServerConfig,
285
- StdioMCPServerConfig,
286
- StreamableHTTPMCPServerConfig,
405
+ node=self,
406
+ pool=self.agent_pool,
407
+ config=self._config,
408
+ definition=defn,
409
+ input_provider=self._input_provider,
410
+ data=data,
287
411
  )
288
412
 
289
- result: dict[str, McpServerConfig] = {}
290
-
291
- for idx, server in enumerate(self._external_mcp_servers):
292
- # Determine server name
293
- if server.name:
294
- name = server.name
295
- elif isinstance(server, StdioMCPServerConfig) and server.args:
296
- name = server.args[-1].split("/")[-1].split("@")[0]
297
- elif isinstance(server, StdioMCPServerConfig):
298
- name = server.command
299
- elif isinstance(server, SSEMCPServerConfig | StreamableHTTPMCPServerConfig):
300
- from urllib.parse import urlparse
301
-
302
- name = urlparse(str(server.url)).hostname or f"server_{idx}"
303
- else:
304
- name = f"server_{idx}"
305
-
306
- # Build SDK-compatible config
307
- config: dict[str, Any]
308
- match server:
309
- case StdioMCPServerConfig(command=command, args=args):
310
- config = {"type": "stdio", "command": command, "args": args}
311
- if server.env:
312
- config["env"] = server.get_env_vars()
313
- case SSEMCPServerConfig(url=url):
314
- config = {"type": "sse", "url": str(url)}
315
- if server.headers:
316
- config["headers"] = server.headers
317
- case StreamableHTTPMCPServerConfig(url=url):
318
- config = {"type": "http", "url": str(url)}
319
- if server.headers:
320
- config["headers"] = server.headers
321
-
322
- result[name] = cast(McpServerConfig, config)
323
-
324
- return result
325
-
326
413
  async def _setup_toolsets(self) -> None:
327
414
  """Initialize toolsets from config and create bridge if needed.
328
415
 
@@ -330,34 +417,30 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
330
417
  and starts an MCP bridge to expose them to Claude Code via the SDK's
331
418
  native MCP support. Also converts external MCP servers to SDK format.
332
419
  """
420
+ from agentpool.agents.claude_code_agent.converters import convert_mcp_servers_to_sdk_format
333
421
  from agentpool.mcp_server.tool_bridge import BridgeConfig, ToolManagerBridge
334
422
 
335
423
  # Convert external MCP servers to SDK format first
336
424
  if self._external_mcp_servers:
337
- external_configs = self._convert_mcp_servers_to_sdk_format()
425
+ external_configs = convert_mcp_servers_to_sdk_format(self._external_mcp_servers)
338
426
  self._mcp_servers.update(external_configs)
339
427
  self.log.info("External MCP servers configured", server_count=len(external_configs))
340
428
 
341
- if not self._config.toolsets:
429
+ if not self._config.tools:
342
430
  return
343
431
 
344
- # Create providers from toolset configs and add to tool manager
345
- for toolset_config in self._config.toolsets:
346
- provider = toolset_config.get_provider()
432
+ # Create providers from tool configs and add to tool manager
433
+ for provider in self._config.get_tool_providers():
347
434
  self.tools.add_provider(provider)
348
-
349
- # Auto-create bridge to expose tools via MCP
350
- config = BridgeConfig(
351
- transport="streamable-http", server_name=f"agentpool-{self.name}-tools"
352
- )
435
+ server_name = f"agentpool-{self.name}-tools"
436
+ config = BridgeConfig(server_name=server_name)
353
437
  self._tool_bridge = ToolManagerBridge(node=self, config=config)
354
438
  await self._tool_bridge.start()
355
439
  self._owns_bridge = True
356
-
357
440
  # Get Claude SDK-compatible MCP config and merge into our servers dict
358
441
  mcp_config = self._tool_bridge.get_claude_mcp_server_config()
359
442
  self._mcp_servers.update(mcp_config)
360
- self.log.info("Toolsets initialized", toolset_count=len(self._config.toolsets))
443
+ self.log.info("Tools initialized", tool_count=len(self._config.tools))
361
444
 
362
445
  async def add_tool_bridge(self, bridge: ToolManagerBridge) -> None:
363
446
  """Add an external tool bridge to expose its tools via MCP.
@@ -372,25 +455,64 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
372
455
  """
373
456
  if self._tool_bridge is None: # Don't replace our own bridge
374
457
  self._tool_bridge = bridge
375
-
376
458
  # Get Claude SDK-compatible config and merge
377
459
  mcp_config = bridge.get_claude_mcp_server_config()
378
460
  self._mcp_servers.update(mcp_config)
379
461
  self.log.info("Added external tool bridge", server_name=bridge.config.server_name)
380
462
 
381
- async def _cleanup_bridge(self) -> None:
382
- """Clean up tool bridge resources."""
383
- if self._tool_bridge and self._owns_bridge:
384
- await self._tool_bridge.stop()
385
- self._tool_bridge = None
386
- self._owns_bridge = False
387
- self._mcp_servers.clear()
388
-
389
463
  @property
390
464
  def model_name(self) -> str | None:
391
465
  """Get the model name."""
392
466
  return self._current_model
393
467
 
468
+ def get_mcp_server_info(self) -> dict[str, MCPServerStatus]:
469
+ """Get information about configured MCP servers.
470
+
471
+ Returns a dict mapping server names to their status info. This is used
472
+ by the OpenCode /mcp endpoint to display MCP servers in the sidebar.
473
+
474
+ Returns:
475
+ Dict mapping server name to MCPServerStatus dataclass
476
+ """
477
+ from agentpool.common_types import MCPServerStatus
478
+
479
+ result: dict[str, MCPServerStatus] = {}
480
+ for name, config in self._mcp_servers.items():
481
+ server_type = config.get("type", "unknown")
482
+ result[name] = MCPServerStatus(
483
+ name=name,
484
+ status="connected", # Claude SDK manages connections
485
+ server_type=server_type,
486
+ )
487
+ return result
488
+
489
+ def _build_hooks(self) -> dict[str, list[Any]]:
490
+ """Build SDK hooks configuration.
491
+
492
+ Returns:
493
+ Dictionary mapping hook event names to HookMatcher lists
494
+ """
495
+ from claude_agent_sdk.types import HookMatcher
496
+
497
+ async def on_pre_compact(
498
+ input_data: HookInput,
499
+ tool_use_id: str | None,
500
+ context: HookContext,
501
+ ) -> SyncHookJSONOutput:
502
+ """Handle PreCompact hook by emitting a CompactionEvent."""
503
+ from agentpool.agents.events import CompactionEvent
504
+
505
+ # input_data is PreCompactHookInput when hook_event_name == "PreCompact"
506
+ trigger_value = input_data.get("trigger", "auto")
507
+ trigger: Literal["auto", "manual"] = "manual" if trigger_value == "manual" else "auto"
508
+ # Emit semantic CompactionEvent - consumers handle display differently
509
+ ses_id = self.conversation_id or "unknown"
510
+ compaction_event = CompactionEvent(session_id=ses_id, trigger=trigger, phase="starting")
511
+ await self._event_queue.put(compaction_event)
512
+ return {"continue_": True}
513
+
514
+ return {"PreCompact": [HookMatcher(matcher=None, hooks=[on_pre_compact])]}
515
+
394
516
  def _build_options(self, *, formatted_system_prompt: str | None = None) -> ClaudeAgentOptions:
395
517
  """Build ClaudeAgentOptions from runtime state.
396
518
 
@@ -400,6 +522,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
400
522
  from claude_agent_sdk import ClaudeAgentOptions
401
523
  from claude_agent_sdk.types import SystemPromptPreset
402
524
 
525
+ from agentpool.agents.claude_code_agent.converters import to_output_format
526
+
403
527
  # Build system prompt value
404
528
  system_prompt: str | SystemPromptPreset | None = None
405
529
  if formatted_system_prompt:
@@ -424,12 +548,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
424
548
  self._can_use_tool if self.tool_confirmation_mode != "never" and not bypass else None
425
549
  )
426
550
 
427
- # Build structured output format if needed
428
- output_format: dict[str, Any] | None = None
429
- if self._output_type is not str:
430
- adapter = TypeAdapter(self._output_type)
431
- schema = adapter.json_schema()
432
- output_format = {"type": "json_schema", "schema": schema}
551
+ # Check builtin_tools for special tools that need extra handling
552
+ builtin_tools = self._builtin_tools or []
553
+
554
+ # Build extra_args for CLI flags not directly exposed
555
+ extra_args: dict[str, str | None] = {}
556
+ if "Chrome" in builtin_tools:
557
+ extra_args["chrome"] = None
558
+
559
+ # Build environment variables
560
+ env = dict(self._environment or {})
561
+ env["CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"] = "1"
562
+ env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
563
+ if "LSP" in builtin_tools:
564
+ # Enable LSP tool support
565
+ env["ENABLE_LSP_TOOL"] = "1"
566
+ if self._use_subscription:
567
+ # Force subscription usage by clearing API key
568
+ env["ANTHROPIC_API_KEY"] = ""
433
569
 
434
570
  return ClaudeAgentOptions(
435
571
  cwd=self._cwd,
@@ -438,16 +574,20 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
438
574
  system_prompt=system_prompt,
439
575
  model=self._model,
440
576
  max_turns=self._max_turns,
577
+ max_budget_usd=self._max_budget_usd,
441
578
  max_thinking_tokens=self._max_thinking_tokens,
442
579
  permission_mode=permission_mode,
443
- env=self._environment or {},
580
+ env=env,
444
581
  add_dirs=self._add_dir or [], # type: ignore[arg-type] # SDK uses list not Sequence
445
582
  tools=self._builtin_tools,
446
583
  fallback_model=self._fallback_model,
447
584
  can_use_tool=can_use_tool,
448
- output_format=output_format,
585
+ output_format=to_output_format(self._output_type),
449
586
  mcp_servers=self._mcp_servers or {},
450
587
  include_partial_messages=True,
588
+ hooks=self._build_hooks(), # type: ignore[arg-type]
589
+ setting_sources=self._setting_sources,
590
+ extra_args=extra_args,
451
591
  )
452
592
 
453
593
  async def _can_use_tool( # noqa: PLR0911
@@ -458,36 +598,70 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
458
598
  ) -> PermissionResult:
459
599
  """Handle tool permission requests.
460
600
 
601
+ This callback fires in two cases:
602
+ 1. Tool needs approval: Claude wants to use a tool that isn't auto-approved
603
+ 2. Claude asks a question: Claude calls the AskUserQuestion tool for clarification
604
+
461
605
  Args:
462
- tool_name: Name of the tool being called
606
+ tool_name: Name of the tool being called (e.g., "Bash", "Write", "AskUserQuestion")
463
607
  input_data: Tool input arguments
464
608
  context: Permission context with suggestions
465
609
 
466
610
  Returns:
467
611
  PermissionResult indicating allow or deny
468
612
  """
613
+ import uuid
614
+
469
615
  from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
470
616
 
471
- from agentpool.tools.base import Tool
617
+ from agentpool.tools import FunctionTool
472
618
 
473
- # Auto-grant if confirmation mode is "never"
619
+ # Handle AskUserQuestion specially - this is Claude asking for clarification
620
+ if tool_name == "AskUserQuestion":
621
+ return await self._handle_clarifying_questions(input_data, context)
622
+
623
+ # Auto-grant if confirmation mode is "never" (bypassPermissions)
474
624
  if self.tool_confirmation_mode == "never":
475
625
  return PermissionResultAllow()
476
626
 
477
- # Auto-grant tools from our own bridge - they already show ToolCallStartEvent in UI
478
- # Bridge tools are named like: mcp__agentpool-{agent_name}-tools__{tool}
479
- if self._tool_bridge:
480
- bridge_prefix = f"mcp__{self._tool_bridge.config.server_name}__"
481
- if tool_name.startswith(bridge_prefix):
627
+ # For "acceptEdits" mode: auto-allow edit/write tools only
628
+ if self._permission_mode == "acceptEdits":
629
+ # Extract the actual tool name from MCP-style names
630
+ # e.g., "mcp__agentpool-claude-tools__edit" -> "edit"
631
+ actual_tool_name = tool_name
632
+ if "__" in tool_name:
633
+ actual_tool_name = tool_name.rsplit("__", 1)[-1]
634
+ # Auto-allow file editing tools
635
+ if actual_tool_name.lower() in ("edit", "write", "edit_file", "write_file"):
482
636
  return PermissionResultAllow()
483
637
 
484
- # Use input provider if available
638
+ # For "default" mode and non-edit tools in "acceptEdits" mode:
639
+ # Ask for confirmation via input provider
485
640
  if self._input_provider:
641
+ # Get tool_use_id from SDK context if available (requires SDK >= 0.1.19)
642
+ # TODO: Remove fallback once claude-agent-sdk with tool_use_id is released
643
+ if hasattr(context, "tool_use_id") and (tc_id := context.tool_use_id): # pyright: ignore[reportAttributeAccessIssue]
644
+ tool_call_id = tc_id
645
+ else:
646
+ # Fallback: look up from streaming events or generate our own
647
+ tool_call_id = self._pending_tool_call_ids.get(tool_name)
648
+ if not tool_call_id:
649
+ tool_call_id = f"perm_{uuid.uuid4().hex[:12]}"
650
+ self._pending_tool_call_ids[tool_name] = tool_call_id
651
+
652
+ display_name = _strip_mcp_prefix(tool_name)
653
+ self.log.debug("Permission request", tool_name=display_name, tool_call_id=tool_call_id)
486
654
  # Create a dummy Tool for the confirmation dialog
487
655
  desc = f"Claude Code tool: {tool_name}"
488
- tool = Tool(callable=lambda: None, name=tool_name, description=desc)
656
+ tool = FunctionTool(callable=lambda: None, name=display_name, description=desc)
657
+ ctx = self.get_context()
658
+ # Attach tool_call_id to context for permission event
659
+ ctx.tool_call_id = tool_call_id
660
+ # Also pass tool input for ACPInputProvider to generate proper title
661
+ ctx.tool_input = input_data
662
+ ctx.tool_name = tool_name
489
663
  result = await self._input_provider.get_tool_confirmation(
490
- context=self.get_context(),
664
+ context=ctx,
491
665
  tool=tool,
492
666
  args=input_data,
493
667
  )
@@ -505,8 +679,157 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
505
679
  # Default: deny if no input provider
506
680
  return PermissionResultDeny(message="No input provider configured")
507
681
 
682
+ async def _handle_clarifying_questions(
683
+ self,
684
+ input_data: dict[str, Any],
685
+ context: ToolPermissionContext,
686
+ ) -> PermissionResult:
687
+ """Handle AskUserQuestion tool - Claude asking for clarification.
688
+
689
+ The input contains Claude's questions with multiple-choice options.
690
+ We present these to the user and return their selections.
691
+
692
+ Users can respond with:
693
+ - A number (1-based index): "2" selects the second option
694
+ - A label: "Summary" (case-insensitive)
695
+ - Free text: "jquery" or "I don't know" (used directly as the answer)
696
+ - Multiple selections (for multi-select): "1, 3" or "Summary, Conclusion"
697
+
698
+ Question format from Claude:
699
+ {
700
+ "questions": [
701
+ {
702
+ "question": "How should I format the output?",
703
+ "header": "Format",
704
+ "options": [
705
+ {"label": "Summary", "description": "Brief overview"},
706
+ {"label": "Detailed", "description": "Full explanation"}
707
+ ],
708
+ "multiSelect": false
709
+ }
710
+ ]
711
+ }
712
+
713
+ Response format:
714
+ {
715
+ "questions": [...], # Original questions passed through
716
+ "answers": {
717
+ "How should I format the output?": "Summary",
718
+ "Which sections?": "Introduction, Conclusion" # Multi-select joined with ", "
719
+ }
720
+ }
721
+
722
+ Args:
723
+ input_data: Contains 'questions' array with question objects
724
+ context: Permission context
725
+
726
+ Returns:
727
+ PermissionResult with updated input containing user's answers
728
+ """
729
+ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
730
+
731
+ if not self._input_provider:
732
+ return PermissionResultDeny(message="No input provider configured for questions")
733
+
734
+ questions = input_data.get("questions", [])
735
+ if not questions:
736
+ return PermissionResultDeny(message="No questions provided")
737
+
738
+ # Collect answers from the user
739
+ answers: dict[str, str] = {}
740
+
741
+ for question_obj in questions:
742
+ question_text = question_obj.get("question", "")
743
+ header = question_obj.get("header", "")
744
+ options = question_obj.get("options", [])
745
+ multi_select = question_obj.get("multiSelect", False)
746
+
747
+ if not question_text or not options:
748
+ continue
749
+
750
+ # Format the question for display
751
+ formatted_question = f"{header}: {question_text}" if header else question_text
752
+ option_labels = [opt.get("label", "") for opt in options]
753
+ option_descriptions = {
754
+ opt.get("label", ""): opt.get("description", "") for opt in options
755
+ }
756
+
757
+ # Get user's answer via input provider
758
+ try:
759
+ # Build a display string showing the options
760
+ options_display = "\n".join(
761
+ f" {i + 1}. {label}"
762
+ + (f" - {option_descriptions[label]}" if option_descriptions[label] else "")
763
+ for i, label in enumerate(option_labels)
764
+ )
765
+ full_prompt = f"{formatted_question}\n\nOptions:\n{options_display}\n\n"
766
+ if multi_select:
767
+ full_prompt += (
768
+ "(Enter numbers separated by commas, or type your own answer)\n"
769
+ "Your choice: "
770
+ )
771
+ else:
772
+ full_prompt += "(Enter a number, or type your own answer)\nYour choice: "
773
+
774
+ # Use input provider to get user response
775
+ ctx = self.get_context()
776
+ user_input = await self._input_provider.get_input(
777
+ context=ctx,
778
+ prompt=full_prompt,
779
+ )
780
+
781
+ if user_input is None:
782
+ return PermissionResultDeny(message="User cancelled question", interrupt=True)
783
+
784
+ # Parse user input - handle numbers, labels, or free text
785
+ # This follows the SDK pattern: try numeric -> try label -> use free text
786
+ if multi_select:
787
+ # Split by comma for multi-select
788
+ selections = [s.strip() for s in user_input.split(",")]
789
+ else:
790
+ selections = [user_input.strip()]
791
+
792
+ selected_values: list[str] = []
793
+ for selection in selections:
794
+ # Try to parse as number first
795
+ if selection.isdigit():
796
+ idx = int(selection) - 1
797
+ if 0 <= idx < len(option_labels):
798
+ # Valid number - use the option's label
799
+ selected_values.append(option_labels[idx])
800
+ else:
801
+ # Invalid number - treat as free text
802
+ selected_values.append(selection)
803
+ else:
804
+ # Try to match label (case-insensitive)
805
+ matching = [
806
+ lbl for lbl in option_labels if lbl.lower() == selection.lower()
807
+ ]
808
+ if matching:
809
+ # Matched a label - use it
810
+ selected_values.append(matching[0])
811
+ else:
812
+ # No match - use as free text
813
+ selected_values.append(selection)
814
+
815
+ # Store answer - join multiple selections with ", "
816
+ # Use free text directly if provided (not "Other")
817
+ answers[question_text] = ", ".join(selected_values)
818
+
819
+ except Exception as e:
820
+ self.log.exception("Error getting clarifying question answer")
821
+ return PermissionResultDeny(message=f"Error collecting answer: {e}", interrupt=True)
822
+
823
+ # Return the answers to Claude
824
+ return PermissionResultAllow(
825
+ updated_input={
826
+ "questions": questions,
827
+ "answers": answers,
828
+ }
829
+ )
830
+
508
831
  async def __aenter__(self) -> Self:
509
- """Connect to Claude Code."""
832
+ """Connect to Claude Code with deferred client connection."""
510
833
  from claude_agent_sdk import ClaudeSDKClient
511
834
 
512
835
  await super().__aenter__()
@@ -514,10 +837,31 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
514
837
  formatted_prompt = await self.sys_prompts.format_system_prompt(self)
515
838
  options = self._build_options(formatted_system_prompt=formatted_prompt)
516
839
  self._client = ClaudeSDKClient(options=options)
517
- await self._client.connect()
518
- self.log.info("Claude Code client connected")
840
+ # Start connection in background task to reduce first-prompt latency
841
+ # The task owns the anyio context, we just await it when needed
842
+ self._connection_task = asyncio.create_task(self._do_connect())
519
843
  return self
520
844
 
845
+ async def _do_connect(self) -> None:
846
+ """Actually connect the client. Runs in background task."""
847
+ if not self._client:
848
+ msg = "Client not created - call __aenter__ first"
849
+ raise RuntimeError(msg)
850
+
851
+ try:
852
+ await self._client.connect()
853
+ await self.populate_commands()
854
+ self.log.info("Claude Code client connected")
855
+ except Exception:
856
+ self.log.exception("Failed to connect Claude Code client")
857
+ raise
858
+
859
+ async def ensure_initialized(self) -> None:
860
+ """Wait for background connection task to complete."""
861
+ if self._connection_task:
862
+ await self._connection_task
863
+ self._connection_task = None
864
+
521
865
  async def __aexit__(
522
866
  self,
523
867
  exc_type: type[BaseException] | None,
@@ -525,8 +869,19 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
525
869
  exc_tb: TracebackType | None,
526
870
  ) -> None:
527
871
  """Disconnect from Claude Code."""
872
+ # Cancel connection task if still running
873
+ if self._connection_task and not self._connection_task.done():
874
+ self._connection_task.cancel()
875
+ with contextlib.suppress(asyncio.CancelledError):
876
+ await self._connection_task
877
+ self._connection_task = None
878
+
528
879
  # Clean up tool bridge first
529
- await self._cleanup_bridge()
880
+ if self._tool_bridge and self._owns_bridge:
881
+ await self._tool_bridge.stop()
882
+ self._tool_bridge = None
883
+ self._owns_bridge = False
884
+ self._mcp_servers.clear()
530
885
  if self._client:
531
886
  try:
532
887
  await self._client.disconnect()
@@ -536,62 +891,129 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
536
891
  self._client = None
537
892
  await super().__aexit__(exc_type, exc_val, exc_tb)
538
893
 
539
- async def run(
540
- self,
541
- *prompts: PromptCompatible,
542
- message_id: str | None = None,
543
- input_provider: InputProvider | None = None,
544
- message_history: MessageHistory | None = None,
545
- ) -> ChatMessage[TResult]:
546
- """Execute prompt against Claude Code.
894
+ async def populate_commands(self) -> None:
895
+ """Populate the command store with slash commands from Claude Code.
896
+
897
+ Fetches available commands from the connected Claude Code server
898
+ and registers them as slashed Commands. Should be called after
899
+ connection is established.
900
+
901
+ Commands that are not supported or not useful for external use
902
+ are filtered out (e.g., login, logout, context, cost).
903
+ """
904
+ server_info = await self.get_server_info()
905
+ if not server_info:
906
+ self.log.warning("No server info available for command population")
907
+ return
908
+ if not server_info.commands:
909
+ self.log.debug("No commands available from Claude Code server")
910
+ return
911
+ # Commands to skip - not useful or problematic in this context
912
+ unsupported = {"login", "logout", "release-notes", "todos"}
913
+ for cmd_info in server_info.commands:
914
+ name = cmd_info.name
915
+ if not name or name in unsupported:
916
+ continue
917
+
918
+ command = self._create_claude_code_command(cmd_info)
919
+ self._command_store.register_command(command)
920
+ command_count = len(self._command_store.list_commands())
921
+ self.log.info("Populated command store", command_count=command_count)
922
+
923
+ def _create_claude_code_command(self, cmd_info: ClaudeCodeCommandInfo) -> Command:
924
+ """Create a slashed Command from Claude Code command info.
547
925
 
548
926
  Args:
549
- prompts: Prompts to send
550
- message_id: Optional message ID for the returned message
551
- input_provider: Optional input provider for permission requests
552
- message_history: Optional MessageHistory to use instead of agent's own
927
+ cmd_info: Command info dict with 'name', 'description', 'argumentHint'
553
928
 
554
929
  Returns:
555
- ChatMessage containing the agent's response
930
+ A slashed Command that executes via Claude Code
556
931
  """
557
- final_message: ChatMessage[TResult] | None = None
558
- async for event in self.run_stream(
559
- *prompts,
560
- message_id=message_id,
561
- input_provider=input_provider,
562
- message_history=message_history,
563
- ):
564
- if isinstance(event, StreamCompleteEvent):
565
- final_message = event.message
566
-
567
- if final_message is None:
568
- msg = "No final message received from stream"
569
- raise RuntimeError(msg)
932
+ from slashed import Command
933
+
934
+ name = cmd_info.name
935
+ # Handle MCP commands - they have " (MCP)" suffix in Claude Code
936
+ category = "claude_code"
937
+ if name.endswith(" (MCP)"):
938
+ name = f"mcp:{name.replace(' (MCP)', '')}"
939
+ category = "mcp"
940
+
941
+ async def execute_command(
942
+ ctx: CommandContext[Any],
943
+ args: list[str],
944
+ kwargs: dict[str, str],
945
+ ) -> None:
946
+ """Execute the Claude Code slash command."""
947
+ from claude_agent_sdk.types import (
948
+ AssistantMessage,
949
+ ResultMessage,
950
+ TextBlock,
951
+ UserMessage,
952
+ )
570
953
 
571
- return final_message
954
+ # Build command string
955
+ args_str = " ".join(args) if args else ""
956
+ if kwargs:
957
+ kwargs_str = " ".join(f"{k}={v}" for k, v in kwargs.items())
958
+ args_str = f"{args_str} {kwargs_str}".strip()
959
+
960
+ full_command = f"/{name} {args_str}".strip()
961
+
962
+ # Execute via agent run - slash commands go through as prompts
963
+ if self._client:
964
+ await self._client.query(full_command)
965
+ async for msg in self._client.receive_response():
966
+ if isinstance(msg, AssistantMessage):
967
+ for block in msg.content:
968
+ if isinstance(block, TextBlock):
969
+ await ctx.print(block.text)
970
+ elif isinstance(msg, UserMessage):
971
+ # Handle local command output wrapped in XML tags
972
+ content = msg.content if isinstance(msg.content, str) else ""
973
+ # Extract content from <local-command-stdout> or <local-command-stderr>
974
+ match = re.search(
975
+ r"<local-command-(?:stdout|stderr)>(.*?)</local-command-(?:stdout|stderr)>",
976
+ content,
977
+ re.DOTALL,
978
+ )
979
+ if match:
980
+ await ctx.print(match.group(1))
981
+ elif isinstance(msg, ResultMessage):
982
+ if msg.result:
983
+ await ctx.print(msg.result)
984
+ if msg.is_error:
985
+ await ctx.print(f"Error: {msg.subtype}")
986
+
987
+ return Command.from_raw(
988
+ execute_command,
989
+ name=name,
990
+ description=cmd_info.description or f"Claude Code command: {name}",
991
+ category=category,
992
+ usage=cmd_info.argument_hint,
993
+ )
572
994
 
573
- async def run_stream( # noqa: PLR0915
995
+ async def _stream_events( # noqa: PLR0915
574
996
  self,
575
- *prompts: PromptCompatible,
997
+ prompts: list[UserContent],
998
+ *,
999
+ user_msg: ChatMessage[Any],
1000
+ effective_parent_id: str | None,
576
1001
  message_id: str | None = None,
1002
+ conversation_id: str | None = None,
1003
+ parent_id: str | None = None,
577
1004
  input_provider: InputProvider | None = None,
578
1005
  message_history: MessageHistory | None = None,
1006
+ deps: TDeps | None = None,
1007
+ event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
1008
+ wait_for_connections: bool | None = None,
1009
+ store_history: bool = True,
579
1010
  ) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
580
- """Stream events from Claude Code execution.
581
-
582
- Args:
583
- prompts: Prompts to send
584
- message_id: Optional message ID for the final message
585
- input_provider: Optional input provider for permission requests
586
- message_history: Optional MessageHistory to use instead of agent's own
587
-
588
- Yields:
589
- RichAgentStreamEvent instances during execution
590
- """
1011
+ from anyenv import MultiEventHandler
591
1012
  from claude_agent_sdk import (
592
1013
  AssistantMessage,
593
1014
  Message,
594
1015
  ResultMessage,
1016
+ SystemMessage,
595
1017
  TextBlock,
596
1018
  ThinkingBlock,
597
1019
  ToolResultBlock,
@@ -600,58 +1022,122 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
600
1022
  )
601
1023
  from claude_agent_sdk.types import StreamEvent
602
1024
 
1025
+ from agentpool.agents.events import resolve_event_handlers
1026
+ from agentpool.agents.events.infer_info import derive_rich_tool_info
1027
+ from agentpool.agents.tool_call_accumulator import ToolCallAccumulator
1028
+
1029
+ # Ensure client is connected (waits for deferred init if needed)
1030
+ await self.ensure_initialized()
603
1031
  # Reset cancellation state
604
1032
  self._cancelled = False
605
- self._current_stream_task = asyncio.current_task()
1033
+ # Initialize conversation_id on first run and log to storage
1034
+ # Use passed conversation_id if provided (e.g., from chained agents)
1035
+ # TODO: decide whether we should store CC sessions ourselves
1036
+ # For Claude Code, session_id comes from the SDK's init message:
1037
+ # if hasattr(message, 'subtype') and message.subtype == 'init':
1038
+ # session_id = message.data.get('session_id')
1039
+ # The SDK manages its own session persistence. To resume, pass:
1040
+ # ClaudeAgentOptions(resume=session_id)
1041
+ # Conversation ID initialization handled by BaseAgent
606
1042
 
607
1043
  # Update input provider if provided
608
1044
  if input_provider is not None:
609
1045
  self._input_provider = input_provider
610
-
611
1046
  if not self._client:
612
- msg = "Agent not initialized - use async context manager"
613
- raise RuntimeError(msg)
1047
+ raise RuntimeError("Agent not initialized - use async context manager")
614
1048
 
615
1049
  conversation = message_history if message_history is not None else self.conversation
616
- # Prepare prompts
617
- user_msg, processed_prompts, _original_message = await prepare_prompts(*prompts)
1050
+ # Use provided event handlers or fall back to agent's handlers
1051
+ if event_handlers is not None:
1052
+ handler: MultiEventHandler[IndividualEventHandler] = MultiEventHandler(
1053
+ resolve_event_handlers(event_handlers)
1054
+ )
1055
+ else:
1056
+ handler = self.event_handler
618
1057
  # Get pending parts from conversation (staged content)
619
1058
  pending_parts = conversation.get_pending_parts()
620
1059
  # Combine pending parts with new prompts, then join into single string for Claude SDK
621
- all_parts = [*pending_parts, *processed_prompts]
1060
+ all_parts = [*pending_parts, *prompts]
622
1061
  prompt_text = " ".join(str(p) for p in all_parts)
1062
+
1063
+ # Inject thinking instruction if enabled
1064
+ if self._thinking_mode == "on":
1065
+ thinking_instruction = THINKING_MODE_PROMPTS[self._thinking_mode]
1066
+ prompt_text = f"{prompt_text}\n\n{thinking_instruction}"
623
1067
  run_id = str(uuid.uuid4())
624
1068
  # Emit run started
1069
+ assert self.conversation_id is not None # Initialized by BaseAgent.run_stream()
625
1070
  run_started = RunStartedEvent(
626
1071
  thread_id=self.conversation_id,
627
1072
  run_id=run_id,
628
1073
  agent_name=self.name,
629
1074
  )
630
- for handler in self.event_handler._wrapped_handlers:
631
- await handler(None, run_started)
1075
+ await handler(None, run_started)
632
1076
  yield run_started
633
1077
  request = ModelRequest(parts=[UserPromptPart(content=prompt_text)])
634
1078
  model_messages: list[ModelResponse | ModelRequest] = [request]
635
1079
  current_response_parts: list[TextPart | ThinkingPart | ToolCallPart] = []
636
1080
  text_chunks: list[str] = []
637
1081
  pending_tool_calls: dict[str, ToolUseBlock] = {}
1082
+ # Track tool calls that already had ToolCallStartEvent emitted (via StreamEvent)
1083
+ emitted_tool_starts: set[str] = set()
1084
+ tool_accumulator = ToolCallAccumulator()
1085
+ # Track files modified during this run
1086
+ file_tracker = FileTracker()
1087
+ # Accumulate metadata events by tool_call_id (workaround for SDK stripping _meta)
1088
+ tool_metadata: dict[str, dict[str, Any]] = {}
1089
+ # Set deps on tool bridge for access during tool invocations
1090
+ # (ContextVar doesn't work because MCP server runs in a separate task)
1091
+ if self._tool_bridge:
1092
+ self._tool_bridge.current_deps = deps
1093
+
1094
+ # Handle ephemeral execution (fork session if store_history=False)
1095
+ fork_client = None
1096
+ active_client = self._client
1097
+
1098
+ if not store_history and self._sdk_session_id:
1099
+ # Create fork client that shares parent's context but has separate session ID
1100
+ # See: src/agentpool/agents/claude_code_agent/FORKING.md
1101
+ from claude_agent_sdk import ClaudeSDKClient
1102
+
1103
+ # Build options using same method as main client
1104
+ fork_options = self._build_options()
1105
+ # Add fork-specific parameters
1106
+ fork_options.resume = self._sdk_session_id # Fork from current session
1107
+ fork_options.fork_session = True # Create new session ID
1108
+
1109
+ fork_client = ClaudeSDKClient(options=fork_options)
1110
+ await fork_client.connect()
1111
+ active_client = fork_client
638
1112
 
639
1113
  try:
640
- await self._client.query(prompt_text)
1114
+ await active_client.query(prompt_text)
641
1115
  # Merge SDK messages with event queue for real-time tool event streaming
642
1116
  async with merge_queue_into_iterator(
643
- self._client.receive_response(), self._event_queue
1117
+ active_client.receive_response(), self._event_queue
644
1118
  ) as merged_events:
645
1119
  async for event_or_message in merged_events:
646
1120
  # Check if it's a queued event (from tools via EventEmitter)
647
1121
  if not isinstance(event_or_message, Message):
1122
+ # Capture metadata events for correlation with tool results
1123
+ from agentpool.agents.events import ToolResultMetadataEvent
1124
+
1125
+ if isinstance(event_or_message, ToolResultMetadataEvent):
1126
+ tool_metadata[event_or_message.tool_call_id] = event_or_message.metadata
1127
+ # Don't yield metadata events - they're internal correlation only
1128
+ continue
648
1129
  # It's an event from the queue - yield it immediately
649
- for handler in self.event_handler._wrapped_handlers:
650
- await handler(None, event_or_message)
1130
+ await handler(None, event_or_message)
651
1131
  yield event_or_message
652
1132
  continue
653
1133
 
654
1134
  message = event_or_message
1135
+ # Capture SDK session ID from init message
1136
+ if isinstance(message, SystemMessage):
1137
+ if message.subtype == "init" and "session_id" in message.data:
1138
+ self._sdk_session_id = message.data["session_id"]
1139
+ continue
1140
+
655
1141
  # Process assistant messages - extract parts incrementally
656
1142
  if isinstance(message, AssistantMessage):
657
1143
  # Update model name from first assistant message
@@ -666,29 +1152,44 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
666
1152
  current_response_parts.append(ThinkingPart(content=thinking))
667
1153
  case ToolUseBlockType(id=tc_id, name=name, input=input_data):
668
1154
  pending_tool_calls[tc_id] = block
669
- current_response_parts.append(
670
- ToolCallPart(
671
- tool_name=name, args=input_data, tool_call_id=tc_id
672
- )
673
- )
674
- # Emit ToolCallStartEvent with rich display info
675
- from agentpool.agents.claude_code_agent.converters import (
676
- derive_rich_tool_info,
1155
+ display_name = _strip_mcp_prefix(name)
1156
+ tool_call_part = ToolCallPart(
1157
+ tool_name=display_name, args=input_data, tool_call_id=tc_id
677
1158
  )
678
-
679
- rich_info = derive_rich_tool_info(name, input_data)
680
- tool_start_event = ToolCallStartEvent(
681
- tool_call_id=tc_id,
682
- tool_name=name,
683
- title=rich_info.title,
684
- kind=rich_info.kind,
685
- locations=rich_info.locations,
686
- content=rich_info.content,
687
- raw_input=input_data,
688
- )
689
- for handler in self.event_handler._wrapped_handlers:
1159
+ current_response_parts.append(tool_call_part)
1160
+
1161
+ # Emit FunctionToolCallEvent (triggers UI notification)
1162
+ # func_tool_event = FunctionToolCallEvent(part=tool_call_part)
1163
+ # await handler(None, func_tool_event)
1164
+ # yield func_tool_event
1165
+
1166
+ # Only emit ToolCallStartEvent if not already emitted
1167
+ # via streaming (emits early with partial info)
1168
+ if tc_id not in emitted_tool_starts:
1169
+ rich_info = derive_rich_tool_info(name, input_data)
1170
+ tool_start_event = ToolCallStartEvent(
1171
+ tool_call_id=tc_id,
1172
+ tool_name=display_name,
1173
+ title=rich_info.title,
1174
+ kind=rich_info.kind,
1175
+ locations=rich_info.locations,
1176
+ content=rich_info.content,
1177
+ raw_input=input_data,
1178
+ )
1179
+ # Track file modifications
1180
+ file_tracker.process_event(tool_start_event)
690
1181
  await handler(None, tool_start_event)
691
- yield tool_start_event
1182
+ yield tool_start_event
1183
+ # Already emitted ToolCallStartEvent early via streaming.
1184
+ # Dont emit a progress update here - it races with
1185
+ # permission requests and causes Zed to cancel the dialog.
1186
+ # Just track file modifications.
1187
+ elif file_path := file_tracker.extractor(
1188
+ display_name, input_data
1189
+ ):
1190
+ file_tracker.touched_files.add(file_path)
1191
+ # Clean up from accumulator (always, both branches)
1192
+ tool_accumulator.complete(tc_id)
692
1193
  case ToolResultBlock(tool_use_id=tc_id, content=content):
693
1194
  # Tool result received - flush response parts and add request
694
1195
  if current_response_parts:
@@ -698,8 +1199,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
698
1199
 
699
1200
  # Get tool name from pending calls
700
1201
  tool_use = pending_tool_calls.pop(tc_id, None)
701
- tool_name = tool_use.name if tool_use else "unknown"
1202
+ tool_name = _strip_mcp_prefix(
1203
+ tool_use.name if tool_use else "unknown"
1204
+ )
702
1205
  tool_input = tool_use.input if tool_use else {}
1206
+
1207
+ # Create ToolReturnPart for the result
1208
+ tool_return_part = ToolReturnPart(
1209
+ tool_name=tool_name, content=content, tool_call_id=tc_id
1210
+ )
1211
+
1212
+ # Emit FunctionToolResultEvent (for session.py to complete UI)
1213
+ func_result_event = FunctionToolResultEvent(
1214
+ result=tool_return_part
1215
+ )
1216
+ await handler(None, func_result_event)
1217
+ yield func_result_event
1218
+
1219
+ # Also emit ToolCallCompleteEvent for consumers that expect it
703
1220
  tool_done_event = ToolCallCompleteEvent(
704
1221
  tool_name=tool_name,
705
1222
  tool_call_id=tc_id,
@@ -707,16 +1224,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
707
1224
  tool_result=content,
708
1225
  agent_name=self.name,
709
1226
  message_id="",
1227
+ metadata=tool_metadata.get(tc_id),
710
1228
  )
711
- for handler in self.event_handler._wrapped_handlers:
712
- await handler(None, tool_done_event)
1229
+ await handler(None, tool_done_event)
713
1230
  yield tool_done_event
714
1231
 
715
1232
  # Add tool return as ModelRequest
716
- part = ToolReturnPart(
717
- tool_name=tool_name, content=content, tool_call_id=tc_id
718
- )
719
- model_messages.append(ModelRequest(parts=[part]))
1233
+ model_messages.append(ModelRequest(parts=[tool_return_part]))
720
1234
 
721
1235
  # Process user messages - may contain tool results
722
1236
  elif isinstance(message, UserMessage):
@@ -738,9 +1252,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
738
1252
 
739
1253
  # Get tool name from pending calls
740
1254
  tool_use = pending_tool_calls.pop(tc_id, None)
741
- tool_name = tool_use.name if tool_use else "unknown"
1255
+ tool_name = _strip_mcp_prefix(
1256
+ tool_use.name if tool_use else "unknown"
1257
+ )
742
1258
  tool_input = tool_use.input if tool_use else {}
743
- # Emit ToolCallCompleteEvent
1259
+
1260
+ # Create ToolReturnPart for the result
1261
+ tool_return_part = ToolReturnPart(
1262
+ tool_name=tool_name,
1263
+ content=result_content,
1264
+ tool_call_id=tc_id,
1265
+ )
1266
+
1267
+ # Emit FunctionToolResultEvent (for session.py to complete UI)
1268
+ func_result_event = FunctionToolResultEvent(result=tool_return_part)
1269
+ await handler(None, func_result_event)
1270
+ yield func_result_event
1271
+
1272
+ # Also emit ToolCallCompleteEvent for consumers that expect it
744
1273
  tool_complete_event = ToolCallCompleteEvent(
745
1274
  tool_name=tool_name,
746
1275
  tool_call_id=tc_id,
@@ -748,17 +1277,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
748
1277
  tool_result=result_content,
749
1278
  agent_name=self.name,
750
1279
  message_id="",
1280
+ metadata=tool_metadata.get(tc_id),
751
1281
  )
752
- for handler in self.event_handler._wrapped_handlers:
753
- await handler(None, tool_complete_event)
1282
+ await handler(None, tool_complete_event)
754
1283
  yield tool_complete_event
1284
+
755
1285
  # Add tool return as ModelRequest
756
- part = ToolReturnPart(
757
- tool_name=tool_name,
758
- content=result_content,
759
- tool_call_id=tc_id,
760
- )
761
- model_messages.append(ModelRequest(parts=[part]))
1286
+ model_messages.append(ModelRequest(parts=[tool_return_part]))
762
1287
 
763
1288
  # Handle StreamEvent for real-time streaming
764
1289
  elif isinstance(message, StreamEvent):
@@ -772,21 +1297,38 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
772
1297
  block_type = content_block.get("type")
773
1298
 
774
1299
  if block_type == "text":
775
- start_event = PartStartEvent(index=index, part=TextPart(content=""))
776
- for handler in self.event_handler._wrapped_handlers:
777
- await handler(None, start_event)
1300
+ start_event = PartStartEvent.text(index=index, content="")
1301
+ await handler(None, start_event)
778
1302
  yield start_event
779
1303
 
780
1304
  elif block_type == "thinking":
781
- thinking_part = ThinkingPart(content="")
782
- start_event = PartStartEvent(index=index, part=thinking_part)
783
- for handler in self.event_handler._wrapped_handlers:
784
- await handler(None, start_event)
1305
+ start_event = PartStartEvent.thinking(index=index, content="")
1306
+ await handler(None, start_event)
785
1307
  yield start_event
786
1308
 
787
1309
  elif block_type == "tool_use":
788
- # Tool use start is handled via AssistantMessage ToolUseBlock
789
- pass
1310
+ # Emit ToolCallStartEvent early (args still streaming)
1311
+ tc_id = content_block.get("id", "")
1312
+ raw_tool_name = content_block.get("name", "")
1313
+ tool_name = _strip_mcp_prefix(raw_tool_name)
1314
+ tool_accumulator.start(tc_id, tool_name)
1315
+ # Track for permission matching - permission callback will use this
1316
+ # Use raw name since SDK uses raw names for permissions
1317
+ self._pending_tool_call_ids[raw_tool_name] = tc_id
1318
+ # Derive rich info with empty args for now
1319
+ rich_info = derive_rich_tool_info(raw_tool_name, {})
1320
+ tool_start_event = ToolCallStartEvent(
1321
+ tool_call_id=tc_id,
1322
+ tool_name=tool_name,
1323
+ title=rich_info.title,
1324
+ kind=rich_info.kind,
1325
+ locations=[], # No locations yet, args not complete
1326
+ content=rich_info.content,
1327
+ raw_input={}, # Empty, will be filled when complete
1328
+ )
1329
+ emitted_tool_starts.add(tc_id)
1330
+ await handler(None, tool_start_event)
1331
+ yield tool_start_event
790
1332
 
791
1333
  # Handle content_block_delta events (text streaming)
792
1334
  elif event_type == "content_block_delta":
@@ -798,26 +1340,46 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
798
1340
  if text_delta:
799
1341
  text_part = TextPartDelta(content_delta=text_delta)
800
1342
  delta_event = PartDeltaEvent(index=index, delta=text_part)
801
- for handler in self.event_handler._wrapped_handlers:
802
- await handler(None, delta_event)
1343
+ await handler(None, delta_event)
803
1344
  yield delta_event
804
1345
 
805
1346
  elif delta_type == "thinking_delta":
806
1347
  thinking_delta = delta.get("thinking", "")
807
1348
  if thinking_delta:
808
- delta = ThinkingPartDelta(content_delta=thinking_delta)
809
- delta_event = PartDeltaEvent(index=index, delta=delta)
810
- for handler in self.event_handler._wrapped_handlers:
811
- await handler(None, delta_event)
1349
+ thinking_part_delta = ThinkingPartDelta(
1350
+ content_delta=thinking_delta
1351
+ )
1352
+ delta_event = PartDeltaEvent(
1353
+ index=index, delta=thinking_part_delta
1354
+ )
1355
+ await handler(None, delta_event)
812
1356
  yield delta_event
813
1357
 
1358
+ elif delta_type == "input_json_delta":
1359
+ # Accumulate tool argument JSON fragments
1360
+ partial_json = delta.get("partial_json", "")
1361
+ if partial_json:
1362
+ # Find which tool call this belongs to by index
1363
+ # The index corresponds to the content block index
1364
+ for tc_id in tool_accumulator._calls:
1365
+ tool_accumulator.add_args(tc_id, partial_json)
1366
+ # Emit PartDeltaEvent with ToolCallPartDelta
1367
+ tool_delta = ToolCallPartDelta(
1368
+ tool_name_delta=None,
1369
+ args_delta=partial_json,
1370
+ tool_call_id=tc_id,
1371
+ )
1372
+ delta_event = PartDeltaEvent(index=index, delta=tool_delta)
1373
+ await handler(None, delta_event)
1374
+ yield delta_event
1375
+ break # Only one tool call streams at a time
1376
+
814
1377
  # Handle content_block_stop events
815
1378
  elif event_type == "content_block_stop":
816
1379
  # We don't have the full part content here, emit with empty part
817
1380
  # The actual content was accumulated via deltas
818
1381
  end_event = PartEndEvent(index=index, part=TextPart(content=""))
819
- for handler in self.event_handler._wrapped_handlers:
820
- await handler(None, end_event)
1382
+ await handler(None, end_event)
821
1383
  yield end_event
822
1384
 
823
1385
  # Skip further processing for StreamEvent - don't duplicate
@@ -832,8 +1394,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
832
1394
  pending_tool_calls={}, # Already handled above
833
1395
  )
834
1396
  for event in events:
835
- for handler in self.event_handler._wrapped_handlers:
836
- await handler(None, event)
1397
+ await handler(None, event)
837
1398
  yield event
838
1399
 
839
1400
  # Check for result (end of response) and capture usage info
@@ -841,54 +1402,60 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
841
1402
  result_message = message
842
1403
  break
843
1404
 
844
- # Check for cancellation
845
- if self._cancelled:
846
- self.log.info("Stream cancelled by user")
847
- # Emit partial response
848
- response_msg = ChatMessage[TResult](
849
- content="".join(text_chunks), # type: ignore[arg-type]
850
- role="assistant",
851
- name=self.name,
852
- message_id=message_id or str(uuid.uuid4()),
853
- conversation_id=self.conversation_id,
854
- model_name=self.model_name,
855
- messages=model_messages,
856
- finish_reason="stop",
857
- )
858
- complete_event = StreamCompleteEvent(message=response_msg)
859
- for handler in self.event_handler._wrapped_handlers:
860
- await handler(None, complete_event)
861
- yield complete_event
862
- return
1405
+ # Note: We do NOT return early on cancellation here.
1406
+ # The SDK docs warn against using break/return to exit receive_response()
1407
+ # early as it can cause asyncio cleanup issues. Instead, we let the
1408
+ # interrupt() call cause the SDK to send a ResultMessage that will
1409
+ # naturally terminate the stream via the isinstance(message, ResultMessage)
1410
+ # check above. The _cancelled flag is checked in process_prompt() to
1411
+ # return the correct stop reason.
863
1412
  else:
864
1413
  result_message = None
865
1414
 
866
1415
  except asyncio.CancelledError:
867
1416
  self.log.info("Stream cancelled via CancelledError")
868
1417
  # Emit partial response on cancellation
1418
+ # Build metadata with file tracking and SDK session ID
1419
+ metadata = file_tracker.get_metadata()
1420
+ if self._sdk_session_id:
1421
+ metadata["sdk_session_id"] = self._sdk_session_id
1422
+
869
1423
  response_msg = ChatMessage[TResult](
870
1424
  content="".join(text_chunks), # type: ignore[arg-type]
871
1425
  role="assistant",
872
1426
  name=self.name,
873
1427
  message_id=message_id or str(uuid.uuid4()),
874
1428
  conversation_id=self.conversation_id,
1429
+ parent_id=user_msg.message_id,
875
1430
  model_name=self.model_name,
876
1431
  messages=model_messages,
877
1432
  finish_reason="stop",
1433
+ metadata=metadata,
878
1434
  )
879
1435
  complete_event = StreamCompleteEvent(message=response_msg)
880
- for handler in self.event_handler._wrapped_handlers:
881
- await handler(None, complete_event)
1436
+ await handler(None, complete_event)
882
1437
  yield complete_event
1438
+ # Post-processing handled by base class
883
1439
  return
884
1440
 
885
1441
  except Exception as e:
886
1442
  error_event = RunErrorEvent(message=str(e), run_id=run_id, agent_name=self.name)
887
- for handler in self.event_handler._wrapped_handlers:
888
- await handler(None, error_event)
1443
+ await handler(None, error_event)
889
1444
  yield error_event
890
1445
  raise
891
1446
 
1447
+ finally:
1448
+ # Disconnect fork client if we created one
1449
+ if fork_client:
1450
+ try:
1451
+ await fork_client.disconnect()
1452
+ except Exception as e: # noqa: BLE001
1453
+ get_logger(__name__).warning(f"Error disconnecting fork client: {e}")
1454
+
1455
+ # Clear deps from tool bridge
1456
+ if self._tool_bridge:
1457
+ self._tool_bridge.current_deps = None
1458
+
892
1459
  # Flush any remaining response parts
893
1460
  if current_response_parts:
894
1461
  model_messages.append(ModelResponse(parts=current_response_parts))
@@ -900,18 +1467,32 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
900
1467
  else "".join(text_chunks)
901
1468
  )
902
1469
 
903
- # Build cost_info from ResultMessage if available
1470
+ # Build cost_info and usage from ResultMessage if available
904
1471
  cost_info: TokenCost | None = None
1472
+ request_usage: RequestUsage | None = None
905
1473
  if result_message and result_message.usage:
906
- usage = result_message.usage
1474
+ usage_dict = result_message.usage
907
1475
  run_usage = RunUsage(
908
- input_tokens=usage.get("input_tokens", 0),
909
- output_tokens=usage.get("output_tokens", 0),
910
- cache_read_tokens=usage.get("cache_read_input_tokens", 0),
911
- cache_write_tokens=usage.get("cache_creation_input_tokens", 0),
1476
+ input_tokens=usage_dict.get("input_tokens", 0),
1477
+ output_tokens=usage_dict.get("output_tokens", 0),
1478
+ cache_read_tokens=usage_dict.get("cache_read_input_tokens", 0),
1479
+ cache_write_tokens=usage_dict.get("cache_creation_input_tokens", 0),
912
1480
  )
913
1481
  total_cost = Decimal(str(result_message.total_cost_usd or 0))
914
1482
  cost_info = TokenCost(token_usage=run_usage, total_cost=total_cost)
1483
+ # Also set usage for OpenCode compatibility
1484
+ request_usage = RequestUsage(
1485
+ input_tokens=usage_dict.get("input_tokens", 0),
1486
+ output_tokens=usage_dict.get("output_tokens", 0),
1487
+ cache_read_tokens=usage_dict.get("cache_read_input_tokens", 0),
1488
+ cache_write_tokens=usage_dict.get("cache_creation_input_tokens", 0),
1489
+ )
1490
+
1491
+ # Determine finish reason - check if we were cancelled
1492
+ # Build metadata with file tracking and SDK session ID
1493
+ metadata = file_tracker.get_metadata()
1494
+ if self._sdk_session_id:
1495
+ metadata["sdk_session_id"] = self._sdk_session_id
915
1496
 
916
1497
  chat_message = ChatMessage[TResult](
917
1498
  content=final_content,
@@ -919,46 +1500,32 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
919
1500
  name=self.name,
920
1501
  message_id=message_id or str(uuid.uuid4()),
921
1502
  conversation_id=self.conversation_id,
1503
+ parent_id=user_msg.message_id,
922
1504
  model_name=self.model_name,
923
1505
  messages=model_messages,
924
1506
  cost_info=cost_info,
1507
+ usage=request_usage or RequestUsage(),
925
1508
  response_time=result_message.duration_ms / 1000 if result_message else None,
1509
+ finish_reason="stop" if self._cancelled else None,
1510
+ metadata=metadata,
926
1511
  )
927
1512
 
928
- # Emit stream complete
1513
+ # Emit stream complete - post-processing handled by base class
929
1514
  complete_event = StreamCompleteEvent[TResult](message=chat_message)
930
- for handler in self.event_handler._wrapped_handlers:
931
- await handler(None, complete_event)
1515
+ await handler(None, complete_event)
932
1516
  yield complete_event
933
- # Record to history
934
- self.message_sent.emit(chat_message)
935
- conversation.add_chat_messages([user_msg, chat_message])
936
-
937
- async def run_iter(
938
- self,
939
- *prompt_groups: Sequence[PromptCompatible],
940
- ) -> AsyncIterator[ChatMessage[TResult]]:
941
- """Run agent sequentially on multiple prompt groups.
942
-
943
- Args:
944
- prompt_groups: Groups of prompts to process sequentially
945
-
946
- Yields:
947
- Response messages in sequence
948
- """
949
- for prompts in prompt_groups:
950
- response = await self.run(*prompts)
951
- yield response
952
1517
 
953
1518
  async def interrupt(self) -> None:
954
1519
  """Interrupt the currently running stream.
955
1520
 
956
- Calls the Claude SDK's native interrupt() method to stop the query,
957
- then cancels the local stream task.
1521
+ Sets the cancelled flag and calls the Claude SDK's native interrupt()
1522
+ method to stop the query. The stream loop checks the flag and returns
1523
+ gracefully - we don't cancel the task ourselves to avoid CancelledError
1524
+ propagation issues.
958
1525
  """
959
1526
  self._cancelled = True
960
1527
 
961
- # Use Claude SDK's native interrupt
1528
+ # Use Claude SDK's native interrupt - this causes the SDK to stop yielding
962
1529
  if self._client:
963
1530
  try:
964
1531
  await self._client.interrupt()
@@ -966,25 +1533,23 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
966
1533
  except Exception:
967
1534
  self.log.exception("Failed to interrupt Claude Code client")
968
1535
 
969
- # Also cancel the current stream task
970
- if self._current_stream_task and not self._current_stream_task.done():
971
- self._current_stream_task.cancel()
972
-
973
- async def set_model(self, model: str) -> None:
1536
+ async def set_model(self, model: AnthropicMaxModelName | str) -> None:
974
1537
  """Set the model for future requests.
975
1538
 
976
- Note: This updates the model for the next query. The client
977
- maintains the connection, so this takes effect on the next query().
978
-
979
1539
  Args:
980
1540
  model: Model name to use
981
1541
  """
982
1542
  self._model = model
983
1543
  self._current_model = model
984
1544
 
1545
+ # Ensure client is connected before setting model
985
1546
  if self._client:
1547
+ await self.ensure_initialized()
986
1548
  await self._client.set_model(model)
987
1549
  self.log.info("Model changed", model=model)
1550
+ else:
1551
+ # Client not created yet, model will be used during _build_options()
1552
+ self.log.info("Model set for initialization", model=model)
988
1553
 
989
1554
  async def set_tool_confirmation_mode(self, mode: ToolConfirmationMode) -> None:
990
1555
  """Set tool confirmation mode.
@@ -993,17 +1558,207 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
993
1558
  mode: Confirmation mode - "always", "never", or "per_tool"
994
1559
  """
995
1560
  self.tool_confirmation_mode = mode
1561
+ # Map confirmation mode to permission mode
1562
+ if mode == "never":
1563
+ self._permission_mode = "bypassPermissions"
1564
+ elif mode in {"always", "per_tool"}:
1565
+ self._permission_mode = "default"
996
1566
  # Update permission mode on client if connected
997
- if self._client and mode == "never":
998
- await self._client.set_permission_mode("bypassPermissions")
999
- elif self._client and mode == "always":
1000
- await self._client.set_permission_mode("default")
1567
+ if self._client and self._permission_mode:
1568
+ await self._client.set_permission_mode(self._permission_mode)
1569
+
1570
+ async def get_available_models(self) -> list[ModelInfo] | None:
1571
+ """Get available models for Claude Code agent.
1572
+
1573
+ Returns a static list of Claude models (opus, sonnet, haiku) since
1574
+ Claude Code SDK only supports these models with simple IDs.
1575
+
1576
+ Returns:
1577
+ List of tokonomics ModelInfo for Claude models
1578
+ """
1579
+ from agentpool.agents.claude_code_agent.static_info import MODELS
1580
+
1581
+ return MODELS
1001
1582
 
1002
- async def get_stats(self) -> MessageStats:
1003
- """Get message statistics."""
1004
- from agentpool.talk.stats import MessageStats
1583
+ async def get_server_info(self) -> ClaudeCodeServerInfo | None:
1584
+ """Get server initialization info from Claude Code.
1005
1585
 
1006
- return MessageStats(messages=list(self.conversation.chat_messages))
1586
+ Returns information from the Claude Code server including:
1587
+ - Available models (opus, sonnet, haiku) with descriptions and pricing
1588
+ - Available slash commands with descriptions and argument hints
1589
+ - Current and available output styles
1590
+ - Account information (token source, API key source)
1591
+ """
1592
+ from agentpool.agents.claude_code_agent.models import ClaudeCodeServerInfo
1593
+
1594
+ if not self._client:
1595
+ self.log.warning("Cannot get server info: not connected")
1596
+ return None
1597
+ # Get raw server info from SDK client
1598
+ raw_info = await self._client.get_server_info()
1599
+ if not raw_info:
1600
+ self.log.warning("No server info available from Claude Code")
1601
+ return None
1602
+ return ClaudeCodeServerInfo.model_validate(raw_info)
1603
+
1604
+ async def get_modes(self) -> list[ModeCategory]:
1605
+ """Get available mode categories for Claude Code agent.
1606
+
1607
+ Claude Code exposes permission modes and model selection.
1608
+
1609
+ Returns:
1610
+ List of ModeCategory for permissions and models
1611
+ """
1612
+ from agentpool.agents.claude_code_agent.static_info import MODES
1613
+ from agentpool.agents.modes import ModeCategory, ModeInfo
1614
+
1615
+ categories: list[ModeCategory] = []
1616
+ # Permission modes
1617
+ current_id = self._permission_mode or "default"
1618
+ if self.tool_confirmation_mode == "never":
1619
+ current_id = "bypassPermissions"
1620
+
1621
+ categories.append(
1622
+ ModeCategory(
1623
+ id="permissions",
1624
+ name="Mode",
1625
+ available_modes=MODES,
1626
+ current_mode_id=current_id,
1627
+ category="mode",
1628
+ )
1629
+ )
1630
+
1631
+ # Model selection
1632
+ models = await self.get_available_models()
1633
+ if models:
1634
+ current_model = self.model_name or (models[0].id if models else "")
1635
+ modes = [
1636
+ ModeInfo(
1637
+ id=m.id,
1638
+ name=m.name or m.id,
1639
+ description=m.description or "",
1640
+ category_id="model",
1641
+ )
1642
+ for m in models
1643
+ ]
1644
+ categories.append(
1645
+ ModeCategory(
1646
+ id="model",
1647
+ name="Model",
1648
+ available_modes=modes,
1649
+ current_mode_id=current_model,
1650
+ category="model",
1651
+ )
1652
+ )
1653
+
1654
+ # Thinking level selection
1655
+ # Only expose if MAX_THINKING_TOKENS is not set (keyword only works without env var)
1656
+ if not self._max_thinking_tokens:
1657
+ thinking_modes = [
1658
+ ModeInfo(
1659
+ id="off",
1660
+ name="Thinking Off",
1661
+ description="No extended thinking",
1662
+ category_id="thinking_level",
1663
+ ),
1664
+ ModeInfo(
1665
+ id="on",
1666
+ name="Thinking On",
1667
+ description="Extended thinking (~32k tokens)",
1668
+ category_id="thinking_level",
1669
+ ),
1670
+ ]
1671
+ categories.append(
1672
+ ModeCategory(
1673
+ id="thinking_level",
1674
+ name="Thinking Level",
1675
+ available_modes=thinking_modes,
1676
+ current_mode_id=self._thinking_mode,
1677
+ category="thought_level",
1678
+ )
1679
+ )
1680
+
1681
+ return categories
1682
+
1683
+ async def set_mode(self, mode: ModeInfo | str, category_id: str | None = None) -> None:
1684
+ """Set a mode within a category.
1685
+
1686
+ For Claude Code, this handles:
1687
+ - "permissions" category: permission modes from the SDK
1688
+ - "model" category: model selection
1689
+ - "thinking_level" category: extended thinking budget allocation
1690
+
1691
+ Args:
1692
+ mode: The mode to set - ModeInfo object or mode ID string
1693
+ category_id: Category ID ("permissions", "model", or "thinking_level")
1694
+
1695
+ Raises:
1696
+ ValueError: If the category or mode is unknown
1697
+ """
1698
+ from agentpool.agents.claude_code_agent.static_info import VALID_MODES
1699
+
1700
+ # Extract mode_id and category from ModeInfo if provided
1701
+ if isinstance(mode, ModeInfo):
1702
+ mode_id = mode.id
1703
+ category_id = category_id or mode.category_id
1704
+ else:
1705
+ mode_id = mode
1706
+
1707
+ # Default to permissions if no category specified
1708
+ if category_id is None:
1709
+ category_id = "permissions"
1710
+
1711
+ if category_id == "permissions":
1712
+ # Map mode_id to PermissionMode
1713
+ if mode_id not in VALID_MODES:
1714
+ msg = f"Unknown permission mode: {mode_id}. Available: {list(VALID_MODES)}"
1715
+ raise ValueError(msg)
1716
+
1717
+ permission_mode: PermissionMode = mode_id # type: ignore[assignment]
1718
+ self._permission_mode = permission_mode
1719
+
1720
+ # Update tool confirmation mode based on permission mode
1721
+ if mode_id == "bypassPermissions":
1722
+ self.tool_confirmation_mode = "never"
1723
+ elif mode_id in ("default", "plan"):
1724
+ self.tool_confirmation_mode = "always"
1725
+
1726
+ # Update SDK client if initialized
1727
+ if self._client:
1728
+ await self.ensure_initialized()
1729
+ await self._client.set_permission_mode(permission_mode)
1730
+ self.log.info("Permission mode changed", mode=mode_id)
1731
+
1732
+ elif category_id == "model":
1733
+ # Validate model exists
1734
+ models = await self.get_available_models()
1735
+ if models:
1736
+ valid_ids = {m.id for m in models}
1737
+ if mode_id not in valid_ids:
1738
+ msg = f"Unknown model: {mode_id}. Available: {valid_ids}"
1739
+ raise ValueError(msg)
1740
+ # Set the model using set_model method
1741
+ await self.set_model(mode_id)
1742
+ self.log.info("Model changed", model=mode_id)
1743
+
1744
+ elif category_id == "thinking_level":
1745
+ # Check if max_thinking_tokens is configured (takes precedence over keyword)
1746
+ if self._max_thinking_tokens:
1747
+ msg = (
1748
+ "Cannot change thinking mode: max_thinking_tokens is configured. "
1749
+ "The envvar MAX_THINKING_TOKENS takes precedence over the 'ultrathink' keyword."
1750
+ )
1751
+ raise ValueError(msg)
1752
+ # Validate thinking mode
1753
+ if mode_id not in THINKING_MODE_PROMPTS:
1754
+ msg = f"Unknown mode: {mode_id}. Available: {list(THINKING_MODE_PROMPTS.keys())}"
1755
+ raise ValueError(msg)
1756
+ self._thinking_mode = mode_id # type: ignore[assignment]
1757
+ self.log.info("Thinking mode changed", mode=mode_id)
1758
+
1759
+ else:
1760
+ msg = f"Unknown category: {category_id}. Available: permissions, model, thinking_level"
1761
+ raise ValueError(msg)
1007
1762
 
1008
1763
 
1009
1764
  if __name__ == "__main__":
@@ -1011,11 +1766,23 @@ if __name__ == "__main__":
1011
1766
 
1012
1767
  os.environ["ANTHROPIC_API_KEY"] = ""
1013
1768
 
1769
+ # async def main() -> None:
1770
+ # """Demo: Basic call to Claude Code."""
1771
+ # async with ClaudeCodeAgent(name="demo", event_handlers=["detailed"]) as agent:
1772
+ # print("Response (streaming): ", end="", flush=True)
1773
+ # async for _ in agent.run_stream("What files are in the current directory?"):
1774
+ # pass
1775
+
1014
1776
  async def main() -> None:
1015
1777
  """Demo: Basic call to Claude Code."""
1016
- async with ClaudeCodeAgent(name="demo", event_handlers=["detailed"]) as agent:
1017
- print("Response (streaming): ", end="", flush=True)
1018
- async for _ in agent.run_stream("What files are in the current directory?"):
1019
- pass
1778
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
1779
+
1780
+ options = ClaudeAgentOptions(include_partial_messages=True)
1781
+ client = ClaudeSDKClient(options=options)
1782
+ await client.connect()
1783
+ prompt = "Do one tool call. list the cwd"
1784
+ await client.query(prompt)
1785
+ async for message in client.receive_response():
1786
+ print(message)
1020
1787
 
1021
1788
  anyio.run(main)