agentpool 2.2.3__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 (250) hide show
  1. acp/__init__.py +0 -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/client/connection.py +38 -29
  7. acp/client/implementations/default_client.py +3 -2
  8. acp/client/implementations/headless_client.py +2 -2
  9. acp/connection.py +2 -2
  10. acp/notifications.py +18 -49
  11. acp/schema/__init__.py +2 -0
  12. acp/schema/agent_responses.py +21 -0
  13. acp/schema/client_requests.py +3 -3
  14. acp/schema/session_state.py +63 -29
  15. acp/task/supervisor.py +2 -2
  16. acp/utils.py +2 -2
  17. agentpool/__init__.py +2 -0
  18. agentpool/agents/acp_agent/acp_agent.py +278 -263
  19. agentpool/agents/acp_agent/acp_converters.py +150 -17
  20. agentpool/agents/acp_agent/client_handler.py +35 -24
  21. agentpool/agents/acp_agent/session_state.py +14 -6
  22. agentpool/agents/agent.py +471 -643
  23. agentpool/agents/agui_agent/agui_agent.py +104 -107
  24. agentpool/agents/agui_agent/helpers.py +3 -4
  25. agentpool/agents/base_agent.py +485 -32
  26. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  27. agentpool/agents/claude_code_agent/__init__.py +13 -1
  28. agentpool/agents/claude_code_agent/claude_code_agent.py +654 -334
  29. agentpool/agents/claude_code_agent/converters.py +4 -141
  30. agentpool/agents/claude_code_agent/models.py +77 -0
  31. agentpool/agents/claude_code_agent/static_info.py +100 -0
  32. agentpool/agents/claude_code_agent/usage.py +242 -0
  33. agentpool/agents/events/__init__.py +22 -0
  34. agentpool/agents/events/builtin_handlers.py +65 -0
  35. agentpool/agents/events/event_emitter.py +3 -0
  36. agentpool/agents/events/events.py +84 -3
  37. agentpool/agents/events/infer_info.py +145 -0
  38. agentpool/agents/events/processors.py +254 -0
  39. agentpool/agents/interactions.py +41 -6
  40. agentpool/agents/modes.py +13 -0
  41. agentpool/agents/slashed_agent.py +5 -4
  42. agentpool/agents/tool_wrapping.py +18 -6
  43. agentpool/common_types.py +35 -21
  44. agentpool/config_resources/acp_assistant.yml +2 -2
  45. agentpool/config_resources/agents.yml +3 -0
  46. agentpool/config_resources/agents_template.yml +1 -0
  47. agentpool/config_resources/claude_code_agent.yml +9 -8
  48. agentpool/config_resources/external_acp_agents.yml +2 -1
  49. agentpool/delegation/base_team.py +4 -30
  50. agentpool/delegation/pool.py +104 -265
  51. agentpool/delegation/team.py +57 -57
  52. agentpool/delegation/teamrun.py +50 -55
  53. agentpool/functional/run.py +10 -4
  54. agentpool/mcp_server/client.py +73 -38
  55. agentpool/mcp_server/conversions.py +54 -13
  56. agentpool/mcp_server/manager.py +9 -23
  57. agentpool/mcp_server/registries/official_registry_client.py +10 -1
  58. agentpool/mcp_server/tool_bridge.py +114 -79
  59. agentpool/messaging/connection_manager.py +11 -10
  60. agentpool/messaging/event_manager.py +5 -5
  61. agentpool/messaging/message_container.py +6 -30
  62. agentpool/messaging/message_history.py +87 -8
  63. agentpool/messaging/messagenode.py +52 -14
  64. agentpool/messaging/messages.py +2 -26
  65. agentpool/messaging/processing.py +10 -22
  66. agentpool/models/__init__.py +1 -1
  67. agentpool/models/acp_agents/base.py +6 -2
  68. agentpool/models/acp_agents/mcp_capable.py +124 -15
  69. agentpool/models/acp_agents/non_mcp.py +0 -23
  70. agentpool/models/agents.py +66 -66
  71. agentpool/models/agui_agents.py +1 -1
  72. agentpool/models/claude_code_agents.py +111 -17
  73. agentpool/models/file_parsing.py +0 -1
  74. agentpool/models/manifest.py +70 -50
  75. agentpool/prompts/conversion_manager.py +1 -1
  76. agentpool/prompts/prompts.py +5 -2
  77. agentpool/resource_providers/__init__.py +2 -0
  78. agentpool/resource_providers/aggregating.py +4 -2
  79. agentpool/resource_providers/base.py +13 -3
  80. agentpool/resource_providers/codemode/code_executor.py +72 -5
  81. agentpool/resource_providers/codemode/helpers.py +2 -2
  82. agentpool/resource_providers/codemode/provider.py +64 -12
  83. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  84. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  85. agentpool/resource_providers/filtering.py +3 -1
  86. agentpool/resource_providers/mcp_provider.py +66 -12
  87. agentpool/resource_providers/plan_provider.py +111 -18
  88. agentpool/resource_providers/pool.py +5 -3
  89. agentpool/resource_providers/resource_info.py +111 -0
  90. agentpool/resource_providers/static.py +2 -2
  91. agentpool/sessions/__init__.py +2 -0
  92. agentpool/sessions/manager.py +2 -3
  93. agentpool/sessions/models.py +9 -6
  94. agentpool/sessions/protocol.py +28 -0
  95. agentpool/sessions/session.py +11 -55
  96. agentpool/storage/manager.py +361 -54
  97. agentpool/talk/registry.py +4 -4
  98. agentpool/talk/talk.py +9 -10
  99. agentpool/testing.py +1 -1
  100. agentpool/tool_impls/__init__.py +6 -0
  101. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  102. agentpool/tool_impls/agent_cli/tool.py +95 -0
  103. agentpool/tool_impls/bash/__init__.py +64 -0
  104. agentpool/tool_impls/bash/helpers.py +35 -0
  105. agentpool/tool_impls/bash/tool.py +171 -0
  106. agentpool/tool_impls/delete_path/__init__.py +70 -0
  107. agentpool/tool_impls/delete_path/tool.py +142 -0
  108. agentpool/tool_impls/download_file/__init__.py +80 -0
  109. agentpool/tool_impls/download_file/tool.py +183 -0
  110. agentpool/tool_impls/execute_code/__init__.py +55 -0
  111. agentpool/tool_impls/execute_code/tool.py +163 -0
  112. agentpool/tool_impls/grep/__init__.py +80 -0
  113. agentpool/tool_impls/grep/tool.py +200 -0
  114. agentpool/tool_impls/list_directory/__init__.py +73 -0
  115. agentpool/tool_impls/list_directory/tool.py +197 -0
  116. agentpool/tool_impls/question/__init__.py +42 -0
  117. agentpool/tool_impls/question/tool.py +127 -0
  118. agentpool/tool_impls/read/__init__.py +104 -0
  119. agentpool/tool_impls/read/tool.py +305 -0
  120. agentpool/tools/__init__.py +2 -1
  121. agentpool/tools/base.py +114 -34
  122. agentpool/tools/manager.py +57 -1
  123. agentpool/ui/base.py +2 -2
  124. agentpool/ui/mock_provider.py +2 -2
  125. agentpool/ui/stdlib_provider.py +2 -2
  126. agentpool/utils/streams.py +21 -96
  127. agentpool/vfs_registry.py +7 -2
  128. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/METADATA +16 -22
  129. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/RECORD +242 -195
  130. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  131. agentpool_cli/__main__.py +20 -0
  132. agentpool_cli/create.py +1 -1
  133. agentpool_cli/serve_acp.py +59 -1
  134. agentpool_cli/serve_opencode.py +1 -1
  135. agentpool_cli/ui.py +557 -0
  136. agentpool_commands/__init__.py +12 -5
  137. agentpool_commands/agents.py +1 -1
  138. agentpool_commands/pool.py +260 -0
  139. agentpool_commands/session.py +1 -1
  140. agentpool_commands/text_sharing/__init__.py +119 -0
  141. agentpool_commands/text_sharing/base.py +123 -0
  142. agentpool_commands/text_sharing/github_gist.py +80 -0
  143. agentpool_commands/text_sharing/opencode.py +462 -0
  144. agentpool_commands/text_sharing/paste_rs.py +59 -0
  145. agentpool_commands/text_sharing/pastebin.py +116 -0
  146. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  147. agentpool_commands/utils.py +31 -32
  148. agentpool_config/__init__.py +30 -2
  149. agentpool_config/agentpool_tools.py +498 -0
  150. agentpool_config/converters.py +1 -1
  151. agentpool_config/event_handlers.py +42 -0
  152. agentpool_config/events.py +1 -1
  153. agentpool_config/forward_targets.py +1 -4
  154. agentpool_config/jinja.py +3 -3
  155. agentpool_config/mcp_server.py +1 -5
  156. agentpool_config/nodes.py +1 -1
  157. agentpool_config/observability.py +44 -0
  158. agentpool_config/session.py +0 -3
  159. agentpool_config/storage.py +38 -39
  160. agentpool_config/task.py +3 -3
  161. agentpool_config/tools.py +11 -28
  162. agentpool_config/toolsets.py +22 -90
  163. agentpool_server/a2a_server/agent_worker.py +307 -0
  164. agentpool_server/a2a_server/server.py +23 -18
  165. agentpool_server/acp_server/acp_agent.py +125 -56
  166. agentpool_server/acp_server/commands/acp_commands.py +46 -216
  167. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +8 -7
  168. agentpool_server/acp_server/event_converter.py +651 -0
  169. agentpool_server/acp_server/input_provider.py +53 -10
  170. agentpool_server/acp_server/server.py +1 -11
  171. agentpool_server/acp_server/session.py +90 -410
  172. agentpool_server/acp_server/session_manager.py +8 -34
  173. agentpool_server/agui_server/server.py +3 -1
  174. agentpool_server/mcp_server/server.py +5 -2
  175. agentpool_server/opencode_server/ENDPOINTS.md +53 -14
  176. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  177. agentpool_server/opencode_server/__init__.py +0 -8
  178. agentpool_server/opencode_server/converters.py +132 -26
  179. agentpool_server/opencode_server/input_provider.py +160 -8
  180. agentpool_server/opencode_server/models/__init__.py +42 -20
  181. agentpool_server/opencode_server/models/app.py +12 -0
  182. agentpool_server/opencode_server/models/events.py +203 -29
  183. agentpool_server/opencode_server/models/mcp.py +19 -0
  184. agentpool_server/opencode_server/models/message.py +18 -1
  185. agentpool_server/opencode_server/models/parts.py +134 -1
  186. agentpool_server/opencode_server/models/question.py +56 -0
  187. agentpool_server/opencode_server/models/session.py +13 -1
  188. agentpool_server/opencode_server/routes/__init__.py +4 -0
  189. agentpool_server/opencode_server/routes/agent_routes.py +33 -2
  190. agentpool_server/opencode_server/routes/app_routes.py +66 -3
  191. agentpool_server/opencode_server/routes/config_routes.py +66 -5
  192. agentpool_server/opencode_server/routes/file_routes.py +184 -5
  193. agentpool_server/opencode_server/routes/global_routes.py +1 -1
  194. agentpool_server/opencode_server/routes/lsp_routes.py +1 -1
  195. agentpool_server/opencode_server/routes/message_routes.py +122 -66
  196. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  197. agentpool_server/opencode_server/routes/pty_routes.py +23 -22
  198. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  199. agentpool_server/opencode_server/routes/session_routes.py +139 -68
  200. agentpool_server/opencode_server/routes/tui_routes.py +1 -1
  201. agentpool_server/opencode_server/server.py +47 -2
  202. agentpool_server/opencode_server/state.py +30 -0
  203. agentpool_storage/__init__.py +0 -4
  204. agentpool_storage/base.py +81 -2
  205. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  206. agentpool_storage/claude_provider/__init__.py +42 -0
  207. agentpool_storage/{claude_provider.py → claude_provider/provider.py} +190 -8
  208. agentpool_storage/file_provider.py +149 -15
  209. agentpool_storage/memory_provider.py +132 -12
  210. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  211. agentpool_storage/opencode_provider/__init__.py +16 -0
  212. agentpool_storage/opencode_provider/helpers.py +414 -0
  213. agentpool_storage/opencode_provider/provider.py +895 -0
  214. agentpool_storage/session_store.py +20 -6
  215. agentpool_storage/sql_provider/sql_provider.py +135 -2
  216. agentpool_storage/sql_provider/utils.py +2 -12
  217. agentpool_storage/zed_provider/__init__.py +16 -0
  218. agentpool_storage/zed_provider/helpers.py +281 -0
  219. agentpool_storage/zed_provider/models.py +130 -0
  220. agentpool_storage/zed_provider/provider.py +442 -0
  221. agentpool_storage/zed_provider.py +803 -0
  222. agentpool_toolsets/__init__.py +0 -2
  223. agentpool_toolsets/builtin/__init__.py +2 -4
  224. agentpool_toolsets/builtin/code.py +4 -4
  225. agentpool_toolsets/builtin/debug.py +115 -40
  226. agentpool_toolsets/builtin/execution_environment.py +54 -165
  227. agentpool_toolsets/builtin/skills.py +0 -77
  228. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  229. agentpool_toolsets/builtin/workers.py +4 -2
  230. agentpool_toolsets/composio_toolset.py +2 -2
  231. agentpool_toolsets/entry_points.py +3 -1
  232. agentpool_toolsets/fsspec_toolset/grep.py +25 -5
  233. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  234. agentpool_toolsets/fsspec_toolset/toolset.py +350 -66
  235. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  236. agentpool_toolsets/mcp_discovery/toolset.py +74 -17
  237. agentpool_toolsets/mcp_run_toolset.py +8 -11
  238. agentpool_toolsets/notifications.py +33 -33
  239. agentpool_toolsets/openapi.py +3 -1
  240. agentpool_toolsets/search_toolset.py +3 -1
  241. agentpool_config/resources.py +0 -33
  242. agentpool_server/acp_server/acp_tools.py +0 -43
  243. agentpool_server/acp_server/commands/spawn.py +0 -210
  244. agentpool_storage/opencode_provider.py +0 -730
  245. agentpool_storage/text_log_provider.py +0 -276
  246. agentpool_toolsets/builtin/chain.py +0 -288
  247. agentpool_toolsets/builtin/user_interaction.py +0 -52
  248. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  249. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  250. {agentpool-2.2.3.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,7 +56,9 @@ Example:
23
56
  from __future__ import annotations
24
57
 
25
58
  import asyncio
59
+ import contextlib
26
60
  from decimal import Decimal
61
+ import re
27
62
  from typing import TYPE_CHECKING, Any, Literal, Self
28
63
  import uuid
29
64
 
@@ -34,7 +69,6 @@ from pydantic_ai import (
34
69
  ModelResponse,
35
70
  PartDeltaEvent,
36
71
  PartEndEvent,
37
- PartStartEvent,
38
72
  RunUsage,
39
73
  TextPart,
40
74
  TextPartDelta,
@@ -50,19 +84,20 @@ from pydantic_ai.usage import RequestUsage
50
84
  from agentpool.agents.base_agent import BaseAgent
51
85
  from agentpool.agents.claude_code_agent.converters import claude_message_to_events
52
86
  from agentpool.agents.events import (
87
+ PartStartEvent,
53
88
  RunErrorEvent,
54
89
  RunStartedEvent,
55
90
  StreamCompleteEvent,
56
91
  ToolCallCompleteEvent,
57
92
  ToolCallStartEvent,
58
93
  )
94
+ from agentpool.agents.events.processors import FileTracker
59
95
  from agentpool.agents.modes import ModeInfo
60
96
  from agentpool.log import get_logger
61
97
  from agentpool.messaging import ChatMessage
62
98
  from agentpool.messaging.messages import TokenCost
63
- from agentpool.messaging.processing import prepare_prompts
64
99
  from agentpool.models.claude_code_agents import ClaudeCodeAgentConfig
65
- from agentpool.utils.streams import FileTracker, merge_queue_into_iterator
100
+ from agentpool.utils.streams import merge_queue_into_iterator
66
101
 
67
102
 
68
103
  if TYPE_CHECKING:
@@ -79,12 +114,18 @@ if TYPE_CHECKING:
79
114
  ToolUseBlock,
80
115
  )
81
116
  from claude_agent_sdk.types import HookContext, HookInput, SyncHookJSONOutput
82
- from evented.configs import EventConfig
117
+ from evented_config import EventConfig
83
118
  from exxec import ExecutionEnvironment
119
+ from pydantic_ai import UserContent
84
120
  from slashed import BaseCommand, Command, CommandContext
85
121
  from tokonomics.model_discovery.model_info import ModelInfo
122
+ from tokonomics.model_names import AnthropicMaxModelName
86
123
  from toprompt import AnyPromptType
87
124
 
125
+ from agentpool.agents.claude_code_agent.models import (
126
+ ClaudeCodeCommandInfo,
127
+ ClaudeCodeServerInfo,
128
+ )
88
129
  from agentpool.agents.context import AgentContext
89
130
  from agentpool.agents.events import RichAgentStreamEvent
90
131
  from agentpool.agents.modes import ModeCategory
@@ -92,11 +133,11 @@ if TYPE_CHECKING:
92
133
  BuiltinEventHandlerType,
93
134
  IndividualEventHandler,
94
135
  MCPServerStatus,
95
- PromptCompatible,
96
136
  )
97
137
  from agentpool.delegation import AgentPool
98
138
  from agentpool.mcp_server.tool_bridge import ToolManagerBridge
99
139
  from agentpool.messaging import MessageHistory
140
+ from agentpool.models.claude_code_agents import SettingSource
100
141
  from agentpool.ui.base import InputProvider
101
142
  from agentpool_config.mcp_server import MCPServerConfig
102
143
  from agentpool_config.nodes import ToolConfirmationMode
@@ -104,14 +145,28 @@ if TYPE_CHECKING:
104
145
 
105
146
  logger = get_logger(__name__)
106
147
 
107
- # Prefix to strip from tool names for cleaner UI display
108
- _MCP_TOOL_PREFIX = "mcp__agentpool-claude-tools__"
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
+ }
109
161
 
110
162
 
111
163
  def _strip_mcp_prefix(tool_name: str) -> str:
112
- """Strip MCP server prefix from tool names for cleaner UI display."""
113
- if tool_name.startswith(_MCP_TOOL_PREFIX):
114
- return tool_name[len(_MCP_TOOL_PREFIX) :]
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
115
170
  return tool_name
116
171
 
117
172
 
@@ -141,7 +196,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
141
196
  disallowed_tools: list[str] | None = None,
142
197
  system_prompt: str | Sequence[str] | None = None,
143
198
  include_builtin_system_prompt: bool = True,
144
- model: str | None = None,
199
+ model: AnthropicMaxModelName | str | None = None,
145
200
  max_turns: int | None = None,
146
201
  max_budget_usd: float | None = None,
147
202
  max_thinking_tokens: int | None = None,
@@ -150,8 +205,10 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
150
205
  environment: dict[str, str] | None = None,
151
206
  add_dir: list[str] | None = None,
152
207
  builtin_tools: list[str] | None = None,
153
- fallback_model: str | None = None,
208
+ fallback_model: AnthropicMaxModelName | str | None = None,
154
209
  dangerously_skip_permissions: bool = False,
210
+ setting_sources: list[SettingSource] | None = None,
211
+ use_subscription: bool = False,
155
212
  env: ExecutionEnvironment | None = None,
156
213
  input_provider: InputProvider | None = None,
157
214
  agent_pool: AgentPool[Any] | None = None,
@@ -182,9 +239,12 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
182
239
  mcp_servers: External MCP servers to connect to (internal format, converted at runtime)
183
240
  environment: Environment variables for the agent process
184
241
  add_dir: Additional directories to allow tool access to
185
- 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
186
244
  fallback_model: Fallback model when default is overloaded
187
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
188
248
  env: Execution environment
189
249
  input_provider: Provider for user input/confirmations
190
250
  agent_pool: Agent pool for multi-agent coordination
@@ -218,6 +278,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
218
278
  builtin_tools=builtin_tools,
219
279
  fallback_model=fallback_model,
220
280
  dangerously_skip_permissions=dangerously_skip_permissions,
281
+ setting_sources=setting_sources,
282
+ use_subscription=use_subscription,
221
283
  )
222
284
 
223
285
  super().__init__(
@@ -259,6 +321,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
259
321
  self._max_budget_usd = max_budget_usd or config.max_budget_usd
260
322
  self._max_thinking_tokens = max_thinking_tokens or config.max_thinking_tokens
261
323
  self._permission_mode: PermissionMode | None = permission_mode or config.permission_mode
324
+ self._thinking_mode: ThinkingMode = "off"
262
325
  self._external_mcp_servers = list(mcp_servers) if mcp_servers else config.get_mcp_servers()
263
326
  self._environment = environment or config.env
264
327
  self._add_dir = add_dir or config.add_dir
@@ -267,10 +330,14 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
267
330
  self._dangerously_skip_permissions = (
268
331
  dangerously_skip_permissions or config.dangerously_skip_permissions
269
332
  )
333
+ self._setting_sources = setting_sources or config.setting_sources
334
+ self._use_subscription = use_subscription or config.use_subscription
270
335
 
271
336
  # Client state
272
337
  self._client: ClaudeSDKClient | None = None
273
- 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
274
341
  self.deps_type = type(None)
275
342
 
276
343
  # ToolBridge state for exposing toolsets via MCP
@@ -282,6 +349,45 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
282
349
  # Maps tool_name to tool_call_id for matching permissions to tool call UI parts
283
350
  self._pending_tool_call_ids: dict[str, str] = {}
284
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
+
285
391
  def get_context(self, data: Any = None) -> AgentContext:
286
392
  """Create a new context for this agent.
287
393
 
@@ -296,7 +402,12 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
296
402
 
297
403
  defn = self.agent_pool.manifest if self.agent_pool else AgentsManifest()
298
404
  return AgentContext(
299
- node=self, pool=self.agent_pool, config=self._config, definition=defn, data=data
405
+ node=self,
406
+ pool=self.agent_pool,
407
+ config=self._config,
408
+ definition=defn,
409
+ input_provider=self._input_provider,
410
+ data=data,
300
411
  )
301
412
 
302
413
  async def _setup_toolsets(self) -> None:
@@ -315,23 +426,21 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
315
426
  self._mcp_servers.update(external_configs)
316
427
  self.log.info("External MCP servers configured", server_count=len(external_configs))
317
428
 
318
- if not self._config.toolsets:
429
+ if not self._config.tools:
319
430
  return
320
431
 
321
- # Create providers from toolset configs and add to tool manager
322
- for toolset_config in self._config.toolsets:
323
- 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():
324
434
  self.tools.add_provider(provider)
325
-
326
435
  server_name = f"agentpool-{self.name}-tools"
327
- config = BridgeConfig(transport="streamable-http", server_name=server_name)
436
+ config = BridgeConfig(server_name=server_name)
328
437
  self._tool_bridge = ToolManagerBridge(node=self, config=config)
329
438
  await self._tool_bridge.start()
330
439
  self._owns_bridge = True
331
440
  # Get Claude SDK-compatible MCP config and merge into our servers dict
332
441
  mcp_config = self._tool_bridge.get_claude_mcp_server_config()
333
442
  self._mcp_servers.update(mcp_config)
334
- self.log.info("Toolsets initialized", toolset_count=len(self._config.toolsets))
443
+ self.log.info("Tools initialized", tool_count=len(self._config.tools))
335
444
 
336
445
  async def add_tool_bridge(self, bridge: ToolManagerBridge) -> None:
337
446
  """Add an external tool bridge to expose its tools via MCP.
@@ -351,14 +460,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
351
460
  self._mcp_servers.update(mcp_config)
352
461
  self.log.info("Added external tool bridge", server_name=bridge.config.server_name)
353
462
 
354
- async def _cleanup_bridge(self) -> None:
355
- """Clean up tool bridge resources."""
356
- if self._tool_bridge and self._owns_bridge:
357
- await self._tool_bridge.stop()
358
- self._tool_bridge = None
359
- self._owns_bridge = False
360
- self._mcp_servers.clear()
361
-
362
463
  @property
363
464
  def model_name(self) -> str | None:
364
465
  """Get the model name."""
@@ -404,20 +505,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
404
505
  # input_data is PreCompactHookInput when hook_event_name == "PreCompact"
405
506
  trigger_value = input_data.get("trigger", "auto")
406
507
  trigger: Literal["auto", "manual"] = "manual" if trigger_value == "manual" else "auto"
407
-
408
508
  # Emit semantic CompactionEvent - consumers handle display differently
409
- compaction_event = CompactionEvent(
410
- session_id=self.conversation_id,
411
- trigger=trigger,
412
- phase="starting",
413
- )
509
+ ses_id = self.conversation_id or "unknown"
510
+ compaction_event = CompactionEvent(session_id=ses_id, trigger=trigger, phase="starting")
414
511
  await self._event_queue.put(compaction_event)
415
-
416
512
  return {"continue_": True}
417
513
 
418
- return {
419
- "PreCompact": [HookMatcher(matcher=None, hooks=[on_pre_compact])],
420
- }
514
+ return {"PreCompact": [HookMatcher(matcher=None, hooks=[on_pre_compact])]}
421
515
 
422
516
  def _build_options(self, *, formatted_system_prompt: str | None = None) -> ClaudeAgentOptions:
423
517
  """Build ClaudeAgentOptions from runtime state.
@@ -454,6 +548,25 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
454
548
  self._can_use_tool if self.tool_confirmation_mode != "never" and not bypass else None
455
549
  )
456
550
 
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"] = ""
569
+
457
570
  return ClaudeAgentOptions(
458
571
  cwd=self._cwd,
459
572
  allowed_tools=self._allowed_tools or [],
@@ -464,7 +577,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
464
577
  max_budget_usd=self._max_budget_usd,
465
578
  max_thinking_tokens=self._max_thinking_tokens,
466
579
  permission_mode=permission_mode,
467
- env=self._environment or {},
580
+ env=env,
468
581
  add_dirs=self._add_dir or [], # type: ignore[arg-type] # SDK uses list not Sequence
469
582
  tools=self._builtin_tools,
470
583
  fallback_model=self._fallback_model,
@@ -473,6 +586,8 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
473
586
  mcp_servers=self._mcp_servers or {},
474
587
  include_partial_messages=True,
475
588
  hooks=self._build_hooks(), # type: ignore[arg-type]
589
+ setting_sources=self._setting_sources,
590
+ extra_args=extra_args,
476
591
  )
477
592
 
478
593
  async def _can_use_tool( # noqa: PLR0911
@@ -483,47 +598,68 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
483
598
  ) -> PermissionResult:
484
599
  """Handle tool permission requests.
485
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
+
486
605
  Args:
487
- tool_name: Name of the tool being called
606
+ tool_name: Name of the tool being called (e.g., "Bash", "Write", "AskUserQuestion")
488
607
  input_data: Tool input arguments
489
608
  context: Permission context with suggestions
490
609
 
491
610
  Returns:
492
611
  PermissionResult indicating allow or deny
493
612
  """
613
+ import uuid
614
+
494
615
  from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
495
616
 
496
- from agentpool.tools.base import Tool
617
+ from agentpool.tools import FunctionTool
497
618
 
498
- # 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)
499
624
  if self.tool_confirmation_mode == "never":
500
625
  return PermissionResultAllow()
501
626
 
502
- # Auto-grant tools from our own bridge - they already show ToolCallStartEvent in UI
503
- # Bridge tools are named like: mcp__agentpool-{agent_name}-tools__{tool}
504
- if self._tool_bridge:
505
- bridge_prefix = f"mcp__{self._tool_bridge.config.server_name}__"
506
- 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"):
507
636
  return PermissionResultAllow()
508
637
 
509
- # Auto-grant tools from configured external MCP servers
510
- # These are explicitly configured by the user, so they should be trusted
511
- # Tool names are like: mcp__{server_name}__{tool_name}
512
- if tool_name.startswith("mcp__") and self._mcp_servers:
513
- for server_name in self._mcp_servers:
514
- if tool_name.startswith(f"mcp__{server_name}__"):
515
- return PermissionResultAllow()
516
-
517
- # Use input provider if available
638
+ # For "default" mode and non-edit tools in "acceptEdits" mode:
639
+ # Ask for confirmation via input provider
518
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)
519
654
  # Create a dummy Tool for the confirmation dialog
520
655
  desc = f"Claude Code tool: {tool_name}"
521
- tool = Tool(callable=lambda: None, name=tool_name, description=desc)
522
- # Get the tool call ID from our tracking dict (set from streaming events)
523
- tool_call_id = self._pending_tool_call_ids.get(tool_name)
656
+ tool = FunctionTool(callable=lambda: None, name=display_name, description=desc)
524
657
  ctx = self.get_context()
525
658
  # Attach tool_call_id to context for permission event
526
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
527
663
  result = await self._input_provider.get_tool_confirmation(
528
664
  context=ctx,
529
665
  tool=tool,
@@ -543,8 +679,157 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
543
679
  # Default: deny if no input provider
544
680
  return PermissionResultDeny(message="No input provider configured")
545
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
+
546
831
  async def __aenter__(self) -> Self:
547
- """Connect to Claude Code."""
832
+ """Connect to Claude Code with deferred client connection."""
548
833
  from claude_agent_sdk import ClaudeSDKClient
549
834
 
550
835
  await super().__aenter__()
@@ -552,10 +837,31 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
552
837
  formatted_prompt = await self.sys_prompts.format_system_prompt(self)
553
838
  options = self._build_options(formatted_system_prompt=formatted_prompt)
554
839
  self._client = ClaudeSDKClient(options=options)
555
- await self._client.connect()
556
- 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())
557
843
  return self
558
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
+
559
865
  async def __aexit__(
560
866
  self,
561
867
  exc_type: type[BaseException] | None,
@@ -563,8 +869,19 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
563
869
  exc_tb: TracebackType | None,
564
870
  ) -> None:
565
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
+
566
879
  # Clean up tool bridge first
567
- 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()
568
885
  if self._client:
569
886
  try:
570
887
  await self._client.disconnect()
@@ -584,36 +901,26 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
584
901
  Commands that are not supported or not useful for external use
585
902
  are filtered out (e.g., login, logout, context, cost).
586
903
  """
587
- if not self._client:
588
- self.log.warning("Cannot populate commands: not connected")
589
- return
590
-
591
- server_info = await self._client.get_server_info()
904
+ server_info = await self.get_server_info()
592
905
  if not server_info:
593
906
  self.log.warning("No server info available for command population")
594
907
  return
595
-
596
- commands = server_info.get("commands", [])
597
- if not commands:
908
+ if not server_info.commands:
598
909
  self.log.debug("No commands available from Claude Code server")
599
910
  return
600
-
601
911
  # Commands to skip - not useful or problematic in this context
602
- unsupported = {"context", "cost", "login", "logout", "release-notes", "todos"}
603
-
604
- for cmd_info in commands:
605
- name = cmd_info.get("name", "")
912
+ unsupported = {"login", "logout", "release-notes", "todos"}
913
+ for cmd_info in server_info.commands:
914
+ name = cmd_info.name
606
915
  if not name or name in unsupported:
607
916
  continue
608
917
 
609
918
  command = self._create_claude_code_command(cmd_info)
610
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)
611
922
 
612
- self.log.info(
613
- "Populated command store", command_count=len(self._command_store.list_commands())
614
- )
615
-
616
- def _create_claude_code_command(self, cmd_info: dict[str, Any]) -> Command:
923
+ def _create_claude_code_command(self, cmd_info: ClaudeCodeCommandInfo) -> Command:
617
924
  """Create a slashed Command from Claude Code command info.
618
925
 
619
926
  Args:
@@ -624,10 +931,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
624
931
  """
625
932
  from slashed import Command
626
933
 
627
- name = cmd_info.get("name", "")
628
- description = cmd_info.get("description", "")
629
- argument_hint = cmd_info.get("argumentHint")
630
-
934
+ name = cmd_info.name
631
935
  # Handle MCP commands - they have " (MCP)" suffix in Claude Code
632
936
  category = "claude_code"
633
937
  if name.endswith(" (MCP)"):
@@ -640,8 +944,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
640
944
  kwargs: dict[str, str],
641
945
  ) -> None:
642
946
  """Execute the Claude Code slash command."""
643
- import re
644
-
645
947
  from claude_agent_sdk.types import (
646
948
  AssistantMessage,
647
949
  ResultMessage,
@@ -685,71 +987,33 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
685
987
  return Command.from_raw(
686
988
  execute_command,
687
989
  name=name,
688
- description=description or f"Claude Code command: {name}",
990
+ description=cmd_info.description or f"Claude Code command: {name}",
689
991
  category=category,
690
- usage=argument_hint,
992
+ usage=cmd_info.argument_hint,
691
993
  )
692
994
 
693
- async def run(
694
- self,
695
- *prompts: PromptCompatible,
696
- message_id: str | None = None,
697
- input_provider: InputProvider | None = None,
698
- message_history: MessageHistory | None = None,
699
- ) -> ChatMessage[TResult]:
700
- """Execute prompt against Claude Code.
701
-
702
- Args:
703
- prompts: Prompts to send
704
- message_id: Optional message ID for the returned message
705
- input_provider: Optional input provider for permission requests
706
- message_history: Optional MessageHistory to use instead of agent's own
707
-
708
- Returns:
709
- ChatMessage containing the agent's response
710
- """
711
- final_message: ChatMessage[TResult] | None = None
712
- async for event in self.run_stream(
713
- *prompts,
714
- message_id=message_id,
715
- input_provider=input_provider,
716
- message_history=message_history,
717
- ):
718
- if isinstance(event, StreamCompleteEvent):
719
- final_message = event.message
720
-
721
- if final_message is None:
722
- msg = "No final message received from stream"
723
- raise RuntimeError(msg)
724
-
725
- return final_message
726
-
727
- async def run_stream( # noqa: PLR0915
995
+ async def _stream_events( # noqa: PLR0915
728
996
  self,
729
- *prompts: PromptCompatible,
997
+ prompts: list[UserContent],
998
+ *,
999
+ user_msg: ChatMessage[Any],
1000
+ effective_parent_id: str | None,
730
1001
  message_id: str | None = None,
1002
+ conversation_id: str | None = None,
1003
+ parent_id: str | None = None,
731
1004
  input_provider: InputProvider | None = None,
732
1005
  message_history: MessageHistory | None = None,
733
1006
  deps: TDeps | None = None,
734
1007
  event_handlers: Sequence[IndividualEventHandler | BuiltinEventHandlerType] | None = None,
1008
+ wait_for_connections: bool | None = None,
1009
+ store_history: bool = True,
735
1010
  ) -> AsyncIterator[RichAgentStreamEvent[TResult]]:
736
- """Stream events from Claude Code execution.
737
-
738
- Args:
739
- prompts: Prompts to send
740
- message_id: Optional message ID for the final message
741
- input_provider: Optional input provider for permission requests
742
- message_history: Optional MessageHistory to use instead of agent's own
743
- deps: Optional dependencies accessible via ctx.data in tools
744
- event_handlers: Optional event handlers for this run (overrides agent's handlers)
745
-
746
- Yields:
747
- RichAgentStreamEvent instances during execution
748
- """
1011
+ from anyenv import MultiEventHandler
749
1012
  from claude_agent_sdk import (
750
1013
  AssistantMessage,
751
1014
  Message,
752
1015
  ResultMessage,
1016
+ SystemMessage,
753
1017
  TextBlock,
754
1018
  ThinkingBlock,
755
1019
  ToolResultBlock,
@@ -758,9 +1022,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
758
1022
  )
759
1023
  from claude_agent_sdk.types import StreamEvent
760
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()
761
1031
  # Reset cancellation state
762
1032
  self._cancelled = False
763
- 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
1042
+
764
1043
  # Update input provider if provided
765
1044
  if input_provider is not None:
766
1045
  self._input_provider = input_provider
@@ -770,28 +1049,24 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
770
1049
  conversation = message_history if message_history is not None else self.conversation
771
1050
  # Use provided event handlers or fall back to agent's handlers
772
1051
  if event_handlers is not None:
773
- from anyenv import MultiEventHandler
774
-
775
- from agentpool.agents.events import resolve_event_handlers
776
-
777
1052
  handler: MultiEventHandler[IndividualEventHandler] = MultiEventHandler(
778
1053
  resolve_event_handlers(event_handlers)
779
1054
  )
780
1055
  else:
781
1056
  handler = self.event_handler
782
- # Prepare prompts
783
- # Get parent_id from last message in history for tree structure
784
- last_msg_id = conversation.get_last_message_id()
785
- user_msg, processed_prompts, _original_message = await prepare_prompts(
786
- *prompts, parent_id=last_msg_id
787
- )
788
1057
  # Get pending parts from conversation (staged content)
789
1058
  pending_parts = conversation.get_pending_parts()
790
1059
  # Combine pending parts with new prompts, then join into single string for Claude SDK
791
- all_parts = [*pending_parts, *processed_prompts]
1060
+ all_parts = [*pending_parts, *prompts]
792
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}"
793
1067
  run_id = str(uuid.uuid4())
794
1068
  # Emit run started
1069
+ assert self.conversation_id is not None # Initialized by BaseAgent.run_stream()
795
1070
  run_started = RunStartedEvent(
796
1071
  thread_id=self.conversation_id,
797
1072
  run_id=run_id,
@@ -806,34 +1081,63 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
806
1081
  pending_tool_calls: dict[str, ToolUseBlock] = {}
807
1082
  # Track tool calls that already had ToolCallStartEvent emitted (via StreamEvent)
808
1083
  emitted_tool_starts: set[str] = set()
809
-
810
- # Accumulator for streaming tool arguments
811
- from agentpool.agents.tool_call_accumulator import ToolCallAccumulator
812
-
813
1084
  tool_accumulator = ToolCallAccumulator()
814
-
815
1085
  # Track files modified during this run
816
1086
  file_tracker = FileTracker()
817
-
1087
+ # Accumulate metadata events by tool_call_id (workaround for SDK stripping _meta)
1088
+ tool_metadata: dict[str, dict[str, Any]] = {}
818
1089
  # Set deps on tool bridge for access during tool invocations
819
1090
  # (ContextVar doesn't work because MCP server runs in a separate task)
820
1091
  if self._tool_bridge:
821
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
1112
+
822
1113
  try:
823
- await self._client.query(prompt_text)
1114
+ await active_client.query(prompt_text)
824
1115
  # Merge SDK messages with event queue for real-time tool event streaming
825
1116
  async with merge_queue_into_iterator(
826
- self._client.receive_response(), self._event_queue
1117
+ active_client.receive_response(), self._event_queue
827
1118
  ) as merged_events:
828
1119
  async for event_or_message in merged_events:
829
1120
  # Check if it's a queued event (from tools via EventEmitter)
830
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
831
1129
  # It's an event from the queue - yield it immediately
832
1130
  await handler(None, event_or_message)
833
1131
  yield event_or_message
834
1132
  continue
835
1133
 
836
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
+
837
1141
  # Process assistant messages - extract parts incrementally
838
1142
  if isinstance(message, AssistantMessage):
839
1143
  # Update model name from first assistant message
@@ -862,10 +1166,6 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
862
1166
  # Only emit ToolCallStartEvent if not already emitted
863
1167
  # via streaming (emits early with partial info)
864
1168
  if tc_id not in emitted_tool_starts:
865
- from agentpool.agents.claude_code_agent.converters import (
866
- derive_rich_tool_info,
867
- )
868
-
869
1169
  rich_info = derive_rich_tool_info(name, input_data)
870
1170
  tool_start_event = ToolCallStartEvent(
871
1171
  tool_call_id=tc_id,
@@ -880,27 +1180,15 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
880
1180
  file_tracker.process_event(tool_start_event)
881
1181
  await handler(None, tool_start_event)
882
1182
  yield tool_start_event
883
- else:
884
- # Already emitted early - emit update with full args
885
- from agentpool.agents.claude_code_agent.converters import (
886
- derive_rich_tool_info,
887
- )
888
-
889
- rich_info = derive_rich_tool_info(name, input_data)
890
- updated_event = ToolCallStartEvent(
891
- tool_call_id=tc_id,
892
- tool_name=display_name,
893
- title=rich_info.title,
894
- kind=rich_info.kind,
895
- locations=rich_info.locations,
896
- content=rich_info.content,
897
- raw_input=input_data,
898
- )
899
- # Track file modifications using derived info
900
- file_tracker.process_event(updated_event)
901
- await handler(None, updated_event)
902
- yield updated_event
903
- # Clean up from accumulator
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)
904
1192
  tool_accumulator.complete(tc_id)
905
1193
  case ToolResultBlock(tool_use_id=tc_id, content=content):
906
1194
  # Tool result received - flush response parts and add request
@@ -936,6 +1224,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
936
1224
  tool_result=content,
937
1225
  agent_name=self.name,
938
1226
  message_id="",
1227
+ metadata=tool_metadata.get(tc_id),
939
1228
  )
940
1229
  await handler(None, tool_done_event)
941
1230
  yield tool_done_event
@@ -988,6 +1277,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
988
1277
  tool_result=result_content,
989
1278
  agent_name=self.name,
990
1279
  message_id="",
1280
+ metadata=tool_metadata.get(tc_id),
991
1281
  )
992
1282
  await handler(None, tool_complete_event)
993
1283
  yield tool_complete_event
@@ -1007,13 +1297,12 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1007
1297
  block_type = content_block.get("type")
1008
1298
 
1009
1299
  if block_type == "text":
1010
- start_event = PartStartEvent(index=index, part=TextPart(content=""))
1300
+ start_event = PartStartEvent.text(index=index, content="")
1011
1301
  await handler(None, start_event)
1012
1302
  yield start_event
1013
1303
 
1014
1304
  elif block_type == "thinking":
1015
- thinking_part = ThinkingPart(content="")
1016
- start_event = PartStartEvent(index=index, part=thinking_part)
1305
+ start_event = PartStartEvent.thinking(index=index, content="")
1017
1306
  await handler(None, start_event)
1018
1307
  yield start_event
1019
1308
 
@@ -1026,12 +1315,7 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1026
1315
  # Track for permission matching - permission callback will use this
1027
1316
  # Use raw name since SDK uses raw names for permissions
1028
1317
  self._pending_tool_call_ids[raw_tool_name] = tc_id
1029
-
1030
1318
  # Derive rich info with empty args for now
1031
- from agentpool.agents.claude_code_agent.converters import (
1032
- derive_rich_tool_info,
1033
- )
1034
-
1035
1319
  rich_info = derive_rich_tool_info(raw_tool_name, {})
1036
1320
  tool_start_event = ToolCallStartEvent(
1037
1321
  tool_call_id=tc_id,
@@ -1131,6 +1415,11 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1131
1415
  except asyncio.CancelledError:
1132
1416
  self.log.info("Stream cancelled via CancelledError")
1133
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
+
1134
1423
  response_msg = ChatMessage[TResult](
1135
1424
  content="".join(text_chunks), # type: ignore[arg-type]
1136
1425
  role="assistant",
@@ -1141,14 +1430,12 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1141
1430
  model_name=self.model_name,
1142
1431
  messages=model_messages,
1143
1432
  finish_reason="stop",
1144
- metadata=file_tracker.get_metadata(),
1433
+ metadata=metadata,
1145
1434
  )
1146
1435
  complete_event = StreamCompleteEvent(message=response_msg)
1147
1436
  await handler(None, complete_event)
1148
1437
  yield complete_event
1149
- # Record to history even on cancellation so context is preserved
1150
- self.message_sent.emit(response_msg)
1151
- conversation.add_chat_messages([user_msg, response_msg])
1438
+ # Post-processing handled by base class
1152
1439
  return
1153
1440
 
1154
1441
  except Exception as e:
@@ -1158,6 +1445,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1158
1445
  raise
1159
1446
 
1160
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
+
1161
1455
  # Clear deps from tool bridge
1162
1456
  if self._tool_bridge:
1163
1457
  self._tool_bridge.current_deps = None
@@ -1195,6 +1489,11 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1195
1489
  )
1196
1490
 
1197
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
1496
+
1198
1497
  chat_message = ChatMessage[TResult](
1199
1498
  content=final_content,
1200
1499
  role="assistant",
@@ -1208,32 +1507,13 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1208
1507
  usage=request_usage or RequestUsage(),
1209
1508
  response_time=result_message.duration_ms / 1000 if result_message else None,
1210
1509
  finish_reason="stop" if self._cancelled else None,
1211
- metadata=file_tracker.get_metadata(),
1510
+ metadata=metadata,
1212
1511
  )
1213
1512
 
1214
- # Emit stream complete
1513
+ # Emit stream complete - post-processing handled by base class
1215
1514
  complete_event = StreamCompleteEvent[TResult](message=chat_message)
1216
1515
  await handler(None, complete_event)
1217
1516
  yield complete_event
1218
- # Record to history
1219
- self.message_sent.emit(chat_message)
1220
- conversation.add_chat_messages([user_msg, chat_message])
1221
-
1222
- async def run_iter(
1223
- self,
1224
- *prompt_groups: Sequence[PromptCompatible],
1225
- ) -> AsyncIterator[ChatMessage[TResult]]:
1226
- """Run agent sequentially on multiple prompt groups.
1227
-
1228
- Args:
1229
- prompt_groups: Groups of prompts to process sequentially
1230
-
1231
- Yields:
1232
- Response messages in sequence
1233
- """
1234
- for prompts in prompt_groups:
1235
- response = await self.run(*prompts)
1236
- yield response
1237
1517
 
1238
1518
  async def interrupt(self) -> None:
1239
1519
  """Interrupt the currently running stream.
@@ -1253,21 +1533,23 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1253
1533
  except Exception:
1254
1534
  self.log.exception("Failed to interrupt Claude Code client")
1255
1535
 
1256
- async def set_model(self, model: str) -> None:
1536
+ async def set_model(self, model: AnthropicMaxModelName | str) -> None:
1257
1537
  """Set the model for future requests.
1258
1538
 
1259
- Note: This updates the model for the next query. The client
1260
- maintains the connection, so this takes effect on the next query().
1261
-
1262
1539
  Args:
1263
1540
  model: Model name to use
1264
1541
  """
1265
1542
  self._model = model
1266
1543
  self._current_model = model
1267
1544
 
1545
+ # Ensure client is connected before setting model
1268
1546
  if self._client:
1547
+ await self.ensure_initialized()
1269
1548
  await self._client.set_model(model)
1270
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)
1271
1553
 
1272
1554
  async def set_tool_confirmation_mode(self, mode: ToolConfirmationMode) -> None:
1273
1555
  """Set tool confirmation mode.
@@ -1276,11 +1558,14 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1276
1558
  mode: Confirmation mode - "always", "never", or "per_tool"
1277
1559
  """
1278
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"
1279
1566
  # Update permission mode on client if connected
1280
- if self._client and mode == "never":
1281
- await self._client.set_permission_mode("bypassPermissions")
1282
- elif self._client and mode == "always":
1283
- 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)
1284
1569
 
1285
1570
  async def get_available_models(self) -> list[ModelInfo] | None:
1286
1571
  """Get available models for Claude Code agent.
@@ -1291,154 +1576,189 @@ class ClaudeCodeAgent[TDeps = None, TResult = str](BaseAgent[TDeps, TResult]):
1291
1576
  Returns:
1292
1577
  List of tokonomics ModelInfo for Claude models
1293
1578
  """
1294
- from tokonomics.model_discovery.model_info import ModelInfo, ModelPricing
1295
-
1296
- # Static Claude Code models - these are the simple IDs the SDK accepts
1297
- # Use id_override to ensure pydantic_ai_id returns simple names like "opus"
1298
- return [
1299
- ModelInfo(
1300
- id="claude-opus-4-20250514",
1301
- name="Claude Opus",
1302
- provider="anthropic",
1303
- description="Claude Opus - most capable model",
1304
- context_window=200000,
1305
- max_output_tokens=32000,
1306
- input_modalities={"text", "image"},
1307
- output_modalities={"text"},
1308
- pricing=ModelPricing(
1309
- prompt=0.000015, # $15 per 1M tokens
1310
- completion=0.000075, # $75 per 1M tokens
1311
- ),
1312
- id_override="opus", # Claude Code SDK uses simple names
1313
- ),
1314
- ModelInfo(
1315
- id="claude-sonnet-4-20250514",
1316
- name="Claude Sonnet",
1317
- provider="anthropic",
1318
- description="Claude Sonnet - balanced performance and speed",
1319
- context_window=200000,
1320
- max_output_tokens=16000,
1321
- input_modalities={"text", "image"},
1322
- output_modalities={"text"},
1323
- pricing=ModelPricing(
1324
- prompt=0.000003, # $3 per 1M tokens
1325
- completion=0.000015, # $15 per 1M tokens
1326
- ),
1327
- id_override="sonnet", # Claude Code SDK uses simple names
1328
- ),
1329
- ModelInfo(
1330
- id="claude-haiku-3-5-20241022",
1331
- name="Claude Haiku",
1332
- provider="anthropic",
1333
- description="Claude Haiku - fast and cost-effective",
1334
- context_window=200000,
1335
- max_output_tokens=8000,
1336
- input_modalities={"text", "image"},
1337
- output_modalities={"text"},
1338
- pricing=ModelPricing(
1339
- prompt=0.0000008, # $0.80 per 1M tokens
1340
- completion=0.000004, # $4 per 1M tokens
1341
- ),
1342
- id_override="haiku", # Claude Code SDK uses simple names
1343
- ),
1344
- ]
1579
+ from agentpool.agents.claude_code_agent.static_info import MODELS
1580
+
1581
+ return MODELS
1345
1582
 
1346
- def get_modes(self) -> list[ModeCategory]:
1583
+ async def get_server_info(self) -> ClaudeCodeServerInfo | None:
1584
+ """Get server initialization info from Claude Code.
1585
+
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]:
1347
1605
  """Get available mode categories for Claude Code agent.
1348
1606
 
1349
- Claude Code exposes permission modes from the SDK.
1607
+ Claude Code exposes permission modes and model selection.
1350
1608
 
1351
1609
  Returns:
1352
- List with single ModeCategory for Claude Code permission modes
1610
+ List of ModeCategory for permissions and models
1353
1611
  """
1612
+ from agentpool.agents.claude_code_agent.static_info import MODES
1354
1613
  from agentpool.agents.modes import ModeCategory, ModeInfo
1355
1614
 
1356
- # Get current mode - map our confirmation mode to Claude's permission mode
1615
+ categories: list[ModeCategory] = []
1616
+ # Permission modes
1357
1617
  current_id = self._permission_mode or "default"
1358
1618
  if self.tool_confirmation_mode == "never":
1359
1619
  current_id = "bypassPermissions"
1360
1620
 
1361
- category_id = "permissions"
1362
- return [
1621
+ categories.append(
1363
1622
  ModeCategory(
1364
- id=category_id,
1623
+ id="permissions",
1365
1624
  name="Mode",
1366
- available_modes=[
1367
- ModeInfo(
1368
- id="default",
1369
- name="Default",
1370
- description="Require confirmation for tool usage",
1371
- category_id=category_id,
1372
- ),
1373
- ModeInfo(
1374
- id="acceptEdits",
1375
- name="Accept Edits",
1376
- description="Auto-approve file edits without confirmation",
1377
- category_id=category_id,
1378
- ),
1379
- ModeInfo(
1380
- id="plan",
1381
- name="Plan",
1382
- description="Planning mode - no tool execution",
1383
- category_id=category_id,
1384
- ),
1385
- ModeInfo(
1386
- id="bypassPermissions",
1387
- name="Bypass Permissions",
1388
- description="Skip all permission checks (use with caution)",
1389
- category_id=category_id,
1390
- ),
1391
- ],
1625
+ available_modes=MODES,
1392
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
+ )
1393
1679
  )
1394
- ]
1680
+
1681
+ return categories
1395
1682
 
1396
1683
  async def set_mode(self, mode: ModeInfo | str, category_id: str | None = None) -> None:
1397
1684
  """Set a mode within a category.
1398
1685
 
1399
- For Claude Code, this handles permission modes from the SDK.
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
1400
1690
 
1401
1691
  Args:
1402
1692
  mode: The mode to set - ModeInfo object or mode ID string
1403
- category_id: Optional category ID (defaults to "permissions")
1693
+ category_id: Category ID ("permissions", "model", or "thinking_level")
1404
1694
 
1405
1695
  Raises:
1406
1696
  ValueError: If the category or mode is unknown
1407
1697
  """
1698
+ from agentpool.agents.claude_code_agent.static_info import VALID_MODES
1699
+
1408
1700
  # Extract mode_id and category from ModeInfo if provided
1409
1701
  if isinstance(mode, ModeInfo):
1410
1702
  mode_id = mode.id
1411
- category_id = category_id or mode.category_id or None
1703
+ category_id = category_id or mode.category_id
1412
1704
  else:
1413
1705
  mode_id = mode
1414
1706
 
1415
- # Default to first (and only) category
1707
+ # Default to permissions if no category specified
1416
1708
  if category_id is None:
1417
1709
  category_id = "permissions"
1418
1710
 
1419
- if category_id != "permissions":
1420
- msg = f"Unknown category: {category_id}. Only 'permissions' is supported."
1421
- raise ValueError(msg)
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)
1422
1716
 
1423
- # Map mode_id to PermissionMode
1424
- valid_modes: set[PermissionMode] = {"default", "acceptEdits", "plan", "bypassPermissions"}
1425
- if mode_id not in valid_modes:
1426
- msg = f"Unknown mode: {mode_id}. Available: {list(valid_modes)}"
1427
- raise ValueError(msg)
1717
+ permission_mode: PermissionMode = mode_id # type: ignore[assignment]
1718
+ self._permission_mode = permission_mode
1428
1719
 
1429
- permission_mode: PermissionMode = mode_id # type: ignore[assignment]
1430
- self._permission_mode = permission_mode
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"
1431
1725
 
1432
- # Update tool confirmation mode based on permission mode
1433
- if mode_id == "bypassPermissions":
1434
- self.tool_confirmation_mode = "never"
1435
- elif mode_id in ("default", "plan"):
1436
- self.tool_confirmation_mode = "always"
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)
1437
1758
 
1438
- # Update SDK client if connected
1439
- if self._client:
1440
- await self._client.set_permission_mode(permission_mode)
1441
- self.log.info("Permission mode changed", mode=mode_id)
1759
+ else:
1760
+ msg = f"Unknown category: {category_id}. Available: permissions, model, thinking_level"
1761
+ raise ValueError(msg)
1442
1762
 
1443
1763
 
1444
1764
  if __name__ == "__main__":