klaude-code 2.0.2__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. klaude_code/app/__init__.py +12 -0
  2. klaude_code/app/runtime.py +215 -0
  3. klaude_code/cli/auth_cmd.py +2 -2
  4. klaude_code/cli/config_cmd.py +2 -2
  5. klaude_code/cli/cost_cmd.py +1 -1
  6. klaude_code/cli/debug.py +12 -36
  7. klaude_code/cli/list_model.py +3 -3
  8. klaude_code/cli/main.py +17 -60
  9. klaude_code/cli/self_update.py +2 -187
  10. klaude_code/cli/session_cmd.py +2 -2
  11. klaude_code/config/config.py +1 -1
  12. klaude_code/config/select_model.py +1 -1
  13. klaude_code/const.py +9 -1
  14. klaude_code/core/agent.py +9 -62
  15. klaude_code/core/agent_profile.py +284 -0
  16. klaude_code/core/executor.py +335 -230
  17. klaude_code/core/manager/llm_clients_builder.py +1 -1
  18. klaude_code/core/manager/sub_agent_manager.py +16 -29
  19. klaude_code/core/reminders.py +64 -99
  20. klaude_code/core/task.py +12 -20
  21. klaude_code/core/tool/__init__.py +5 -17
  22. klaude_code/core/tool/context.py +84 -0
  23. klaude_code/core/tool/file/apply_patch_tool.py +18 -21
  24. klaude_code/core/tool/file/edit_tool.py +39 -42
  25. klaude_code/core/tool/file/read_tool.py +14 -9
  26. klaude_code/core/tool/file/write_tool.py +12 -13
  27. klaude_code/core/tool/report_back_tool.py +4 -1
  28. klaude_code/core/tool/shell/bash_tool.py +6 -11
  29. klaude_code/core/tool/skill/skill_tool.py +3 -1
  30. klaude_code/core/tool/sub_agent_tool.py +8 -7
  31. klaude_code/core/tool/todo/todo_write_tool.py +3 -9
  32. klaude_code/core/tool/todo/update_plan_tool.py +3 -5
  33. klaude_code/core/tool/tool_abc.py +2 -1
  34. klaude_code/core/tool/tool_registry.py +2 -33
  35. klaude_code/core/tool/tool_runner.py +13 -10
  36. klaude_code/core/tool/web/mermaid_tool.py +3 -1
  37. klaude_code/core/tool/web/web_fetch_tool.py +5 -3
  38. klaude_code/core/tool/web/web_search_tool.py +5 -3
  39. klaude_code/core/turn.py +86 -26
  40. klaude_code/llm/anthropic/client.py +1 -1
  41. klaude_code/llm/bedrock/client.py +1 -1
  42. klaude_code/llm/claude/client.py +1 -1
  43. klaude_code/llm/codex/client.py +1 -1
  44. klaude_code/llm/google/client.py +1 -1
  45. klaude_code/llm/openai_compatible/client.py +1 -1
  46. klaude_code/llm/openai_compatible/tool_call_accumulator.py +1 -1
  47. klaude_code/llm/openrouter/client.py +1 -1
  48. klaude_code/llm/openrouter/reasoning.py +1 -1
  49. klaude_code/llm/responses/client.py +1 -1
  50. klaude_code/protocol/events/__init__.py +57 -0
  51. klaude_code/protocol/events/base.py +18 -0
  52. klaude_code/protocol/events/chat.py +20 -0
  53. klaude_code/protocol/events/lifecycle.py +22 -0
  54. klaude_code/protocol/events/metadata.py +15 -0
  55. klaude_code/protocol/events/streaming.py +43 -0
  56. klaude_code/protocol/events/system.py +53 -0
  57. klaude_code/protocol/events/tools.py +23 -0
  58. klaude_code/protocol/op.py +5 -0
  59. klaude_code/session/session.py +6 -5
  60. klaude_code/skill/assets/create-plan/SKILL.md +76 -0
  61. klaude_code/skill/loader.py +1 -1
  62. klaude_code/skill/system_skills.py +1 -1
  63. klaude_code/tui/__init__.py +8 -0
  64. klaude_code/{command → tui/command}/clear_cmd.py +2 -1
  65. klaude_code/{command → tui/command}/debug_cmd.py +3 -2
  66. klaude_code/{command → tui/command}/export_cmd.py +2 -1
  67. klaude_code/{command → tui/command}/export_online_cmd.py +2 -1
  68. klaude_code/{command → tui/command}/fork_session_cmd.py +4 -3
  69. klaude_code/{command → tui/command}/help_cmd.py +2 -1
  70. klaude_code/{command → tui/command}/model_cmd.py +4 -3
  71. klaude_code/{command → tui/command}/model_select.py +2 -2
  72. klaude_code/{command → tui/command}/prompt_command.py +4 -3
  73. klaude_code/{command → tui/command}/refresh_cmd.py +3 -1
  74. klaude_code/{command → tui/command}/registry.py +6 -5
  75. klaude_code/{command → tui/command}/release_notes_cmd.py +2 -1
  76. klaude_code/{command → tui/command}/resume_cmd.py +4 -3
  77. klaude_code/{command → tui/command}/status_cmd.py +2 -1
  78. klaude_code/{command → tui/command}/terminal_setup_cmd.py +2 -1
  79. klaude_code/{command → tui/command}/thinking_cmd.py +3 -2
  80. klaude_code/tui/commands.py +164 -0
  81. klaude_code/{ui/renderers → tui/components}/assistant.py +3 -3
  82. klaude_code/{ui/renderers → tui/components}/bash_syntax.py +2 -2
  83. klaude_code/{ui/renderers → tui/components}/common.py +1 -1
  84. klaude_code/{ui/renderers → tui/components}/developer.py +4 -4
  85. klaude_code/{ui/renderers → tui/components}/diffs.py +2 -2
  86. klaude_code/{ui/renderers → tui/components}/errors.py +2 -2
  87. klaude_code/{ui/renderers → tui/components}/metadata.py +7 -7
  88. klaude_code/{ui → tui/components}/rich/markdown.py +9 -23
  89. klaude_code/{ui → tui/components}/rich/status.py +2 -2
  90. klaude_code/{ui → tui/components}/rich/theme.py +3 -1
  91. klaude_code/{ui/renderers → tui/components}/sub_agent.py +23 -43
  92. klaude_code/{ui/renderers → tui/components}/thinking.py +3 -3
  93. klaude_code/{ui/renderers → tui/components}/tools.py +9 -9
  94. klaude_code/{ui/renderers → tui/components}/user_input.py +3 -20
  95. klaude_code/tui/display.py +85 -0
  96. klaude_code/{ui/modes/repl → tui/input}/__init__.py +1 -1
  97. klaude_code/{ui/modes/repl → tui/input}/completers.py +1 -1
  98. klaude_code/{ui/modes/repl/input_prompt_toolkit.py → tui/input/prompt_toolkit.py} +6 -6
  99. klaude_code/tui/machine.py +606 -0
  100. klaude_code/tui/renderer.py +707 -0
  101. klaude_code/tui/runner.py +321 -0
  102. klaude_code/tui/terminal/__init__.py +56 -0
  103. klaude_code/{ui → tui}/terminal/color.py +1 -1
  104. klaude_code/{ui → tui}/terminal/control.py +1 -1
  105. klaude_code/{ui → tui}/terminal/notifier.py +1 -1
  106. klaude_code/ui/__init__.py +6 -50
  107. klaude_code/ui/core/display.py +3 -3
  108. klaude_code/ui/core/input.py +2 -1
  109. klaude_code/ui/{modes/debug/display.py → debug_mode.py} +1 -1
  110. klaude_code/ui/{modes/exec/display.py → exec_mode.py} +0 -2
  111. klaude_code/ui/terminal/__init__.py +6 -54
  112. klaude_code/ui/terminal/title.py +31 -0
  113. klaude_code/update.py +163 -0
  114. {klaude_code-2.0.2.dist-info → klaude_code-2.1.0.dist-info}/METADATA +1 -1
  115. klaude_code-2.1.0.dist-info/RECORD +235 -0
  116. klaude_code/cli/runtime.py +0 -518
  117. klaude_code/core/prompt.py +0 -108
  118. klaude_code/core/tool/tool_context.py +0 -148
  119. klaude_code/protocol/events.py +0 -195
  120. klaude_code/skill/assets/dev-docs/SKILL.md +0 -108
  121. klaude_code/trace/__init__.py +0 -21
  122. klaude_code/ui/core/stage_manager.py +0 -48
  123. klaude_code/ui/modes/__init__.py +0 -1
  124. klaude_code/ui/modes/debug/__init__.py +0 -1
  125. klaude_code/ui/modes/exec/__init__.py +0 -1
  126. klaude_code/ui/modes/repl/display.py +0 -61
  127. klaude_code/ui/modes/repl/event_handler.py +0 -629
  128. klaude_code/ui/modes/repl/renderer.py +0 -464
  129. klaude_code/ui/utils/__init__.py +0 -1
  130. klaude_code-2.0.2.dist-info/RECORD +0 -227
  131. /klaude_code/{trace/log.py → log.py} +0 -0
  132. /klaude_code/{command → tui/command}/__init__.py +0 -0
  133. /klaude_code/{command → tui/command}/command_abc.py +0 -0
  134. /klaude_code/{command → tui/command}/prompt-commit.md +0 -0
  135. /klaude_code/{command → tui/command}/prompt-init.md +0 -0
  136. /klaude_code/{ui/renderers → tui/components}/__init__.py +0 -0
  137. /klaude_code/{ui/renderers → tui/components}/mermaid_viewer.py +0 -0
  138. /klaude_code/{ui → tui/components}/rich/__init__.py +0 -0
  139. /klaude_code/{ui → tui/components}/rich/cjk_wrap.py +0 -0
  140. /klaude_code/{ui → tui/components}/rich/code_panel.py +0 -0
  141. /klaude_code/{ui → tui/components}/rich/live.py +0 -0
  142. /klaude_code/{ui → tui/components}/rich/quote.py +0 -0
  143. /klaude_code/{ui → tui/components}/rich/searchable_text.py +0 -0
  144. /klaude_code/{ui/modes/repl → tui/input}/clipboard.py +0 -0
  145. /klaude_code/{ui/modes/repl → tui/input}/key_bindings.py +0 -0
  146. /klaude_code/{ui → tui}/terminal/image.py +0 -0
  147. /klaude_code/{ui → tui}/terminal/progress_bar.py +0 -0
  148. /klaude_code/{ui → tui}/terminal/selector.py +0 -0
  149. /klaude_code/ui/{utils/common.py → common.py} +0 -0
  150. {klaude_code-2.0.2.dist-info → klaude_code-2.1.0.dist-info}/WHEEL +0 -0
  151. {klaude_code-2.0.2.dist-info → klaude_code-2.1.0.dist-info}/entry_points.txt +0 -0
@@ -10,22 +10,22 @@ from __future__ import annotations
10
10
  import asyncio
11
11
  import subprocess
12
12
  import sys
13
- from collections.abc import Callable
13
+ from collections.abc import Awaitable, Callable
14
14
  from dataclasses import dataclass
15
15
  from pathlib import Path
16
16
 
17
17
  from klaude_code.config import load_config
18
- from klaude_code.core.agent import Agent, DefaultModelProfileProvider, ModelProfileProvider
18
+ from klaude_code.core.agent import Agent
19
+ from klaude_code.core.agent_profile import DefaultModelProfileProvider, ModelProfileProvider
19
20
  from klaude_code.core.manager import LLMClients, SubAgentManager
20
- from klaude_code.core.tool import current_run_subtask_callback
21
21
  from klaude_code.llm.registry import create_llm_client
22
+ from klaude_code.log import DebugType, log_debug
22
23
  from klaude_code.protocol import commands, events, message, model, op
23
- from klaude_code.protocol.llm_param import Thinking
24
+ from klaude_code.protocol.llm_param import LLMConfigParameter, Thinking
24
25
  from klaude_code.protocol.op_handler import OperationHandler
25
26
  from klaude_code.protocol.sub_agent import SubAgentResult
26
27
  from klaude_code.session.export import build_export_html, get_default_export_path
27
28
  from klaude_code.session.session import Session
28
- from klaude_code.trace import DebugType, log_debug
29
29
 
30
30
 
31
31
  @dataclass
@@ -80,45 +80,26 @@ class TaskManager:
80
80
  self._tasks.clear()
81
81
 
82
82
 
83
- class ExecutorContext:
84
- """
85
- Context object providing shared state and operations for the executor.
86
-
87
- This context is passed to operations when they execute, allowing them
88
- to access shared resources like the event queue and active sessions.
89
-
90
- Implements the OperationHandler protocol via structural subtyping.
91
- """
83
+ class AgentRuntime:
84
+ """Coordinate agent lifecycle and in-flight tasks for the executor."""
92
85
 
93
86
  def __init__(
94
87
  self,
95
- event_queue: asyncio.Queue[events.Event],
88
+ *,
89
+ emit_event: Callable[[events.Event], Awaitable[None]],
96
90
  llm_clients: LLMClients,
97
- model_profile_provider: ModelProfileProvider | None = None,
98
- on_model_change: Callable[[str], None] | None = None,
99
- ):
100
- self.event_queue: asyncio.Queue[events.Event] = event_queue
101
- self.llm_clients: LLMClients = llm_clients
102
-
103
- resolved_profile_provider = model_profile_provider or DefaultModelProfileProvider()
104
- self.model_profile_provider: ModelProfileProvider = resolved_profile_provider
105
-
106
- self.task_manager = TaskManager()
107
- self.sub_agent_manager = SubAgentManager(event_queue, llm_clients, resolved_profile_provider)
108
- self._on_model_change = on_model_change
91
+ model_profile_provider: ModelProfileProvider,
92
+ task_manager: TaskManager,
93
+ sub_agent_manager: SubAgentManager,
94
+ ) -> None:
95
+ self._emit_event = emit_event
96
+ self._llm_clients = llm_clients
97
+ self._model_profile_provider = model_profile_provider
98
+ self._task_manager = task_manager
99
+ self._sub_agent_manager = sub_agent_manager
109
100
  self._agent: Agent | None = None
110
101
 
111
- async def emit_event(self, event: events.Event) -> None:
112
- """Emit an event to the UI display system."""
113
- await self.event_queue.put(event)
114
-
115
102
  def current_session_id(self) -> str | None:
116
- """Return the primary active session id, if any.
117
-
118
- This is a convenience wrapper used by the CLI, which conceptually
119
- operates on a single interactive session per process.
120
- """
121
-
122
103
  agent = self._agent
123
104
  if agent is None:
124
105
  return None
@@ -126,19 +107,11 @@ class ExecutorContext:
126
107
 
127
108
  @property
128
109
  def current_agent(self) -> Agent | None:
129
- """Return the currently active agent, if any."""
130
-
131
110
  return self._agent
132
111
 
133
- async def _ensure_agent(self, session_id: str | None = None) -> Agent:
134
- """Return the active agent, creating or loading a session as needed.
112
+ async def ensure_agent(self, session_id: str | None = None) -> Agent:
113
+ """Return the active agent, creating or loading a session as needed."""
135
114
 
136
- If ``session_id`` is ``None``, a new session is created with an
137
- auto-generated ID. If provided, the executor attempts to resume the
138
- session from disk or creates a new one if not found.
139
- """
140
-
141
- # Fast-path: reuse current agent when the session id already matches.
142
115
  if session_id is not None and self._agent is not None and self._agent.session.id == session_id:
143
116
  return self._agent
144
117
 
@@ -147,20 +120,21 @@ class ExecutorContext:
147
120
  if (
148
121
  session.model_thinking is not None
149
122
  and session.model_name
150
- and session.model_name == self.llm_clients.main.model_name
123
+ and session.model_name == self._llm_clients.main.model_name
151
124
  ):
152
- self.llm_clients.main.get_llm_config().thinking = session.model_thinking
125
+ self._llm_clients.main.get_llm_config().thinking = session.model_thinking
153
126
 
154
- profile = self.model_profile_provider.build_profile(self.llm_clients.main)
127
+ profile = self._model_profile_provider.build_profile(self._llm_clients.main)
155
128
  agent = Agent(session=session, profile=profile)
156
129
 
157
130
  async for evt in agent.replay_history():
158
- await self.emit_event(evt)
131
+ await self._emit_event(evt)
159
132
 
160
- await self.emit_event(
133
+ await self._emit_event(
161
134
  events.WelcomeEvent(
135
+ session_id=session.id,
162
136
  work_dir=str(session.work_dir),
163
- llm_config=self.llm_clients.main.get_llm_config(),
137
+ llm_config=self._llm_clients.main.get_llm_config(),
164
138
  )
165
139
  )
166
140
 
@@ -172,35 +146,296 @@ class ExecutorContext:
172
146
  )
173
147
  return agent
174
148
 
175
- async def handle_init_agent(self, operation: op.InitAgentOperation) -> None:
176
- """Initialize an agent for a session and replay history to UI."""
177
- await self._ensure_agent(operation.session_id)
149
+ async def init_agent(self, session_id: str | None) -> None:
150
+ await self.ensure_agent(session_id)
178
151
 
179
- async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
180
- agent = await self._ensure_agent(operation.session_id)
181
- existing_active = self.task_manager.get(operation.id)
152
+ async def run_agent(self, operation: op.RunAgentOperation) -> None:
153
+ agent = await self.ensure_agent(operation.session_id)
154
+
155
+ if operation.emit_user_message_event:
156
+ await self._emit_event(
157
+ events.UserMessageEvent(
158
+ content=operation.input.text,
159
+ session_id=agent.session.id,
160
+ images=operation.input.images,
161
+ )
162
+ )
163
+
164
+ if operation.persist_user_input:
165
+ agent.session.append_history(
166
+ [
167
+ message.UserMessage(
168
+ parts=message.parts_from_text_and_images(
169
+ operation.input.text,
170
+ operation.input.images,
171
+ )
172
+ )
173
+ ]
174
+ )
175
+
176
+ existing_active = self._task_manager.get(operation.id)
182
177
  if existing_active is not None and not existing_active.task.done():
183
178
  raise RuntimeError(f"Active task already registered for operation {operation.id}")
179
+
184
180
  task: asyncio.Task[None] = asyncio.create_task(
185
181
  self._run_agent_task(agent, operation.input, operation.id, operation.session_id)
186
182
  )
187
- self.task_manager.register(operation.id, task, operation.session_id)
183
+ self._task_manager.register(operation.id, task, operation.session_id)
188
184
 
189
- async def handle_change_model(self, operation: op.ChangeModelOperation) -> None:
190
- agent = await self._ensure_agent(operation.session_id)
191
- config = load_config()
185
+ async def clear_session(self, session_id: str) -> None:
186
+ agent = await self.ensure_agent(session_id)
187
+ new_session = Session.create(work_dir=agent.session.work_dir)
188
+ new_session.model_name = agent.session.model_name
189
+ new_session.model_config_name = agent.session.model_config_name
190
+ new_session.model_thinking = agent.session.model_thinking
191
+ agent.session = new_session
192
+
193
+ developer_item = message.DeveloperMessage(
194
+ parts=message.text_parts_from_str("started new conversation"),
195
+ ui_extra=model.build_command_output_extra(commands.CommandName.CLEAR),
196
+ )
197
+ await self._emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
198
+ await self._emit_event(
199
+ events.WelcomeEvent(
200
+ session_id=agent.session.id,
201
+ work_dir=str(agent.session.work_dir),
202
+ llm_config=self._llm_clients.main.get_llm_config(),
203
+ )
204
+ )
205
+
206
+ async def resume_session(self, target_session_id: str) -> None:
207
+ target_session = Session.load(target_session_id)
208
+ if (
209
+ target_session.model_thinking is not None
210
+ and target_session.model_name
211
+ and target_session.model_name == self._llm_clients.main.model_name
212
+ ):
213
+ self._llm_clients.main.get_llm_config().thinking = target_session.model_thinking
214
+
215
+ profile = self._model_profile_provider.build_profile(self._llm_clients.main)
216
+ agent = Agent(session=target_session, profile=profile)
217
+
218
+ async for evt in agent.replay_history():
219
+ await self._emit_event(evt)
220
+
221
+ await self._emit_event(
222
+ events.WelcomeEvent(
223
+ session_id=target_session.id,
224
+ work_dir=str(target_session.work_dir),
225
+ llm_config=self._llm_clients.main.get_llm_config(),
226
+ )
227
+ )
228
+
229
+ self._agent = agent
230
+ log_debug(
231
+ f"Resumed session: {target_session.id}",
232
+ style="cyan",
233
+ debug_type=DebugType.EXECUTION,
234
+ )
235
+
236
+ async def interrupt(self, target_session_id: str | None) -> None:
237
+ if target_session_id is not None:
238
+ session_ids: list[str] = [target_session_id]
239
+ else:
240
+ agent = self._agent
241
+ session_ids = [agent.session.id] if agent is not None else []
242
+
243
+ for sid in session_ids:
244
+ agent = self._get_active_agent(sid)
245
+ if agent is not None:
246
+ for evt in agent.cancel():
247
+ await self._emit_event(evt)
248
+
249
+ await self._emit_event(events.InterruptEvent(session_id=target_session_id or "all"))
250
+
251
+ if target_session_id is None:
252
+ session_filter: set[str] | None = None
253
+ else:
254
+ session_filter = {target_session_id}
255
+
256
+ tasks_to_cancel = self._task_manager.cancel_tasks_for_sessions(session_filter)
257
+
258
+ scope = target_session_id or "all"
259
+ log_debug(
260
+ f"Interrupting {len(tasks_to_cancel)} task(s) for: {scope}",
261
+ style="yellow",
262
+ debug_type=DebugType.EXECUTION,
263
+ )
264
+
265
+ for task_id, task in tasks_to_cancel:
266
+ task.cancel()
267
+ self._task_manager.remove(task_id)
268
+
269
+ async def _run_agent_task(
270
+ self,
271
+ agent: Agent,
272
+ user_input: message.UserInputPayload,
273
+ task_id: str,
274
+ session_id: str,
275
+ ) -> None:
276
+ try:
277
+ log_debug(
278
+ f"Starting agent task {task_id} for session {session_id}",
279
+ style="green",
280
+ debug_type=DebugType.EXECUTION,
281
+ )
282
+
283
+ async def _runner(
284
+ state: model.SubAgentState,
285
+ record_session_id: Callable[[str], None] | None,
286
+ ) -> SubAgentResult:
287
+ return await self._sub_agent_manager.run_sub_agent(agent, state, record_session_id=record_session_id)
288
+
289
+ async for event in agent.run_task(user_input, run_subtask=_runner):
290
+ await self._emit_event(event)
291
+
292
+ except asyncio.CancelledError:
293
+ log_debug(
294
+ f"Agent task {task_id} was cancelled",
295
+ style="yellow",
296
+ debug_type=DebugType.EXECUTION,
297
+ )
298
+ await self._emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
299
+
300
+ except Exception as e:
301
+ import traceback
192
302
 
193
- llm_config = config.get_model_config(operation.model_name)
303
+ log_debug(
304
+ f"Agent task {task_id} failed: {e!s}",
305
+ style="red",
306
+ debug_type=DebugType.EXECUTION,
307
+ )
308
+ log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
309
+ await self._emit_event(
310
+ events.ErrorEvent(
311
+ error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s} {traceback.format_exc()}",
312
+ can_retry=False,
313
+ session_id=session_id,
314
+ )
315
+ )
316
+ finally:
317
+ self._task_manager.remove(task_id)
318
+ log_debug(
319
+ f"Cleaned up agent task {task_id}",
320
+ style="cyan",
321
+ debug_type=DebugType.EXECUTION,
322
+ )
323
+
324
+ def _get_active_agent(self, session_id: str) -> Agent | None:
325
+ agent = self._agent
326
+ if agent is None:
327
+ return None
328
+ if agent.session.id != session_id:
329
+ return None
330
+ return agent
331
+
332
+
333
+ class ModelSwitcher:
334
+ """Apply model changes to an agent session."""
335
+
336
+ def __init__(self, model_profile_provider: ModelProfileProvider) -> None:
337
+ self._model_profile_provider = model_profile_provider
338
+
339
+ async def change_model(
340
+ self,
341
+ agent: Agent,
342
+ *,
343
+ model_name: str,
344
+ save_as_default: bool,
345
+ ) -> tuple[LLMConfigParameter, str]:
346
+ config = load_config()
347
+ llm_config = config.get_model_config(model_name)
194
348
  llm_client = create_llm_client(llm_config)
195
- agent.set_model_profile(self.model_profile_provider.build_profile(llm_client))
349
+ agent.set_model_profile(self._model_profile_provider.build_profile(llm_client))
196
350
 
197
- agent.session.model_config_name = operation.model_name
351
+ agent.session.model_config_name = model_name
198
352
  agent.session.model_thinking = llm_config.thinking
199
353
 
200
- if operation.save_as_default:
201
- config.main_model = operation.model_name
354
+ if save_as_default:
355
+ config.main_model = model_name
202
356
  await config.save()
203
357
 
358
+ return llm_config, llm_client.model_name
359
+
360
+ def change_thinking(self, agent: Agent, *, thinking: Thinking) -> Thinking | None:
361
+ """Apply thinking configuration to the agent's active LLM config and persisted session."""
362
+
363
+ config = agent.profile.llm_client.get_llm_config()
364
+ previous = config.thinking
365
+ config.thinking = thinking
366
+ agent.session.model_thinking = thinking
367
+ return previous
368
+
369
+
370
+ class ExecutorContext:
371
+ """
372
+ Context object providing shared state and operations for the executor.
373
+
374
+ This context is passed to operations when they execute, allowing them
375
+ to access shared resources like the event queue and active sessions.
376
+
377
+ Implements the OperationHandler protocol via structural subtyping.
378
+ """
379
+
380
+ def __init__(
381
+ self,
382
+ event_queue: asyncio.Queue[events.Event],
383
+ llm_clients: LLMClients,
384
+ model_profile_provider: ModelProfileProvider | None = None,
385
+ on_model_change: Callable[[str], None] | None = None,
386
+ ):
387
+ self.event_queue: asyncio.Queue[events.Event] = event_queue
388
+ self.llm_clients: LLMClients = llm_clients
389
+
390
+ resolved_profile_provider = model_profile_provider or DefaultModelProfileProvider()
391
+ self.model_profile_provider: ModelProfileProvider = resolved_profile_provider
392
+
393
+ self.task_manager = TaskManager()
394
+ self.sub_agent_manager = SubAgentManager(event_queue, llm_clients, resolved_profile_provider)
395
+ self._on_model_change = on_model_change
396
+ self._agent_runtime = AgentRuntime(
397
+ emit_event=self.emit_event,
398
+ llm_clients=llm_clients,
399
+ model_profile_provider=resolved_profile_provider,
400
+ task_manager=self.task_manager,
401
+ sub_agent_manager=self.sub_agent_manager,
402
+ )
403
+ self._model_switcher = ModelSwitcher(resolved_profile_provider)
404
+
405
+ async def emit_event(self, event: events.Event) -> None:
406
+ """Emit an event to the UI display system."""
407
+ await self.event_queue.put(event)
408
+
409
+ def current_session_id(self) -> str | None:
410
+ """Return the primary active session id, if any.
411
+
412
+ This is a convenience wrapper used by the CLI, which conceptually
413
+ operates on a single interactive session per process.
414
+ """
415
+
416
+ return self._agent_runtime.current_session_id()
417
+
418
+ @property
419
+ def current_agent(self) -> Agent | None:
420
+ """Return the currently active agent, if any."""
421
+
422
+ return self._agent_runtime.current_agent
423
+
424
+ async def handle_init_agent(self, operation: op.InitAgentOperation) -> None:
425
+ """Initialize an agent for a session and replay history to UI."""
426
+ await self._agent_runtime.init_agent(operation.session_id)
427
+
428
+ async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
429
+ await self._agent_runtime.run_agent(operation)
430
+
431
+ async def handle_change_model(self, operation: op.ChangeModelOperation) -> None:
432
+ agent = await self._agent_runtime.ensure_agent(operation.session_id)
433
+ llm_config, llm_client_name = await self._model_switcher.change_model(
434
+ agent,
435
+ model_name=operation.model_name,
436
+ save_as_default=operation.save_as_default,
437
+ )
438
+
204
439
  if operation.emit_switch_message:
205
440
  default_note = " (saved as default)" if operation.save_as_default else ""
206
441
  developer_item = message.DeveloperMessage(
@@ -211,12 +446,15 @@ class ExecutorContext:
211
446
  await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
212
447
 
213
448
  if self._on_model_change is not None:
214
- self._on_model_change(llm_client.model_name)
449
+ self._on_model_change(llm_client_name)
215
450
 
216
451
  if operation.emit_welcome_event:
217
452
  await self.emit_event(
218
453
  events.WelcomeEvent(
219
- llm_config=llm_config, work_dir=str(agent.session.work_dir), show_klaude_code_info=False
454
+ session_id=agent.session.id,
455
+ llm_config=llm_config,
456
+ work_dir=str(agent.session.work_dir),
457
+ show_klaude_code_info=False,
220
458
  )
221
459
  )
222
460
 
@@ -226,9 +464,7 @@ class ExecutorContext:
226
464
  Interactive thinking selection must happen in the UI/CLI layer. Core only
227
465
  applies a concrete thinking configuration.
228
466
  """
229
- agent = await self._ensure_agent(operation.session_id)
230
-
231
- config = agent.profile.llm_client.get_llm_config()
467
+ agent = await self._agent_runtime.ensure_agent(operation.session_id)
232
468
 
233
469
  def _format_thinking_for_display(thinking: Thinking | None) -> str:
234
470
  if thinking is None:
@@ -246,10 +482,9 @@ class ExecutorContext:
246
482
  if operation.thinking is None:
247
483
  raise ValueError("thinking must be provided; interactive selection belongs to UI")
248
484
 
249
- current = _format_thinking_for_display(config.thinking)
250
- config.thinking = operation.thinking
251
- agent.session.model_thinking = operation.thinking
252
- new_status = _format_thinking_for_display(config.thinking)
485
+ previous = self._model_switcher.change_thinking(agent, thinking=operation.thinking)
486
+ current = _format_thinking_for_display(previous)
487
+ new_status = _format_thinking_for_display(operation.thinking)
253
488
 
254
489
  if operation.emit_switch_message:
255
490
  developer_item = message.DeveloperMessage(
@@ -262,63 +497,21 @@ class ExecutorContext:
262
497
  if operation.emit_welcome_event:
263
498
  await self.emit_event(
264
499
  events.WelcomeEvent(
265
- work_dir=str(agent.session.work_dir), llm_config=config, show_klaude_code_info=False
500
+ session_id=agent.session.id,
501
+ work_dir=str(agent.session.work_dir),
502
+ llm_config=agent.profile.llm_client.get_llm_config(),
503
+ show_klaude_code_info=False,
266
504
  )
267
505
  )
268
506
 
269
507
  async def handle_clear_session(self, operation: op.ClearSessionOperation) -> None:
270
- agent = await self._ensure_agent(operation.session_id)
271
- new_session = Session.create(work_dir=agent.session.work_dir)
272
- new_session.model_name = agent.session.model_name
273
- new_session.model_config_name = agent.session.model_config_name
274
- new_session.model_thinking = agent.session.model_thinking
275
- agent.session = new_session
276
-
277
- developer_item = message.DeveloperMessage(
278
- parts=message.text_parts_from_str("started new conversation"),
279
- ui_extra=model.build_command_output_extra(commands.CommandName.CLEAR),
280
- )
281
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
282
- await self.emit_event(
283
- events.WelcomeEvent(
284
- work_dir=str(agent.session.work_dir),
285
- llm_config=self.llm_clients.main.get_llm_config(),
286
- )
287
- )
508
+ await self._agent_runtime.clear_session(operation.session_id)
288
509
 
289
510
  async def handle_resume_session(self, operation: op.ResumeSessionOperation) -> None:
290
- target_session = Session.load(operation.target_session_id)
291
- if (
292
- target_session.model_thinking is not None
293
- and target_session.model_name
294
- and target_session.model_name == self.llm_clients.main.model_name
295
- ):
296
- self.llm_clients.main.get_llm_config().thinking = target_session.model_thinking
297
-
298
- profile = self.model_profile_provider.build_profile(self.llm_clients.main)
299
- from klaude_code.core.agent import Agent
300
-
301
- agent = Agent(session=target_session, profile=profile)
302
-
303
- async for evt in agent.replay_history():
304
- await self.emit_event(evt)
305
-
306
- await self.emit_event(
307
- events.WelcomeEvent(
308
- work_dir=str(target_session.work_dir),
309
- llm_config=self.llm_clients.main.get_llm_config(),
310
- )
311
- )
312
-
313
- self._agent = agent
314
- log_debug(
315
- f"Resumed session: {target_session.id}",
316
- style="cyan",
317
- debug_type=DebugType.EXECUTION,
318
- )
511
+ await self._agent_runtime.resume_session(operation.target_session_id)
319
512
 
320
513
  async def handle_export_session(self, operation: op.ExportSessionOperation) -> None:
321
- agent = await self._ensure_agent(operation.session_id)
514
+ agent = await self._agent_runtime.ensure_agent(operation.session_id)
322
515
  try:
323
516
  output_path = self._resolve_export_output_path(operation.output_path, agent.session)
324
517
  html_doc = self._build_export_html(agent)
@@ -341,61 +534,6 @@ class ExecutorContext:
341
534
  agent.session.append_history([developer_item])
342
535
  await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
343
536
 
344
- async def _run_agent_task(
345
- self,
346
- agent: Agent,
347
- user_input: message.UserInputPayload,
348
- task_id: str,
349
- session_id: str,
350
- ) -> None:
351
- try:
352
- log_debug(
353
- f"Starting agent task {task_id} for session {session_id}",
354
- style="green",
355
- debug_type=DebugType.EXECUTION,
356
- )
357
-
358
- async def _runner(state: model.SubAgentState) -> SubAgentResult:
359
- return await self.sub_agent_manager.run_sub_agent(agent, state)
360
-
361
- token = current_run_subtask_callback.set(_runner)
362
- try:
363
- async for event in agent.run_task(user_input):
364
- await self.emit_event(event)
365
- finally:
366
- current_run_subtask_callback.reset(token)
367
-
368
- except asyncio.CancelledError:
369
- log_debug(
370
- f"Agent task {task_id} was cancelled",
371
- style="yellow",
372
- debug_type=DebugType.EXECUTION,
373
- )
374
- await self.emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
375
-
376
- except Exception as e:
377
- import traceback
378
-
379
- log_debug(
380
- f"Agent task {task_id} failed: {e!s}",
381
- style="red",
382
- debug_type=DebugType.EXECUTION,
383
- )
384
- log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
385
- await self.emit_event(
386
- events.ErrorEvent(
387
- error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s} {traceback.format_exc()}",
388
- can_retry=False,
389
- )
390
- )
391
- finally:
392
- self.task_manager.remove(task_id)
393
- log_debug(
394
- f"Cleaned up agent task {task_id}",
395
- style="cyan",
396
- debug_type=DebugType.EXECUTION,
397
- )
398
-
399
537
  def _resolve_export_output_path(self, raw: str | None, session: Session) -> Path:
400
538
  trimmed = (raw or "").strip()
401
539
  if trimmed:
@@ -440,43 +578,7 @@ class ExecutorContext:
440
578
  async def handle_interrupt(self, operation: op.InterruptOperation) -> None:
441
579
  """Handle an interrupt by invoking agent.cancel() and cancelling tasks."""
442
580
 
443
- # Determine affected sessions
444
- if operation.target_session_id is not None:
445
- session_ids: list[str] = [operation.target_session_id]
446
- else:
447
- agent = self._agent
448
- session_ids = [agent.session.id] if agent is not None else []
449
-
450
- # Call cancel() on each affected agent to persist an interrupt marker
451
- for sid in session_ids:
452
- agent = self._get_active_agent(sid)
453
- if agent is not None:
454
- for evt in agent.cancel():
455
- await self.emit_event(evt)
456
-
457
- # emit interrupt event
458
- await self.emit_event(events.InterruptEvent(session_id=operation.target_session_id or "all"))
459
-
460
- # Find tasks to cancel (filter by target sessions if provided)
461
- if operation.target_session_id is None:
462
- session_filter: set[str] | None = None
463
- else:
464
- session_filter = {operation.target_session_id}
465
-
466
- tasks_to_cancel = self.task_manager.cancel_tasks_for_sessions(session_filter)
467
-
468
- scope = operation.target_session_id or "all"
469
- log_debug(
470
- f"Interrupting {len(tasks_to_cancel)} task(s) for: {scope}",
471
- style="yellow",
472
- debug_type=DebugType.EXECUTION,
473
- )
474
-
475
- # Cancel the tasks
476
- for task_id, task in tasks_to_cancel:
477
- task.cancel()
478
- # Remove from active tasks immediately
479
- self.task_manager.remove(task_id)
581
+ await self._agent_runtime.interrupt(operation.target_session_id)
480
582
 
481
583
  def get_active_task(self, submission_id: str) -> asyncio.Task[None] | None:
482
584
  """Return the asyncio.Task for a submission id if one is registered."""
@@ -491,16 +593,6 @@ class ExecutorContext:
491
593
 
492
594
  return self.task_manager.get(submission_id) is not None
493
595
 
494
- def _get_active_agent(self, session_id: str) -> Agent | None:
495
- """Return the active agent if its session id matches ``session_id``."""
496
-
497
- agent = self._agent
498
- if agent is None:
499
- return None
500
- if agent.session.id != session_id:
501
- return None
502
- return agent
503
-
504
596
 
505
597
  class Executor:
506
598
  """
@@ -598,7 +690,11 @@ class Executor:
598
690
  debug_type=DebugType.EXECUTION,
599
691
  )
600
692
  await self.context.emit_event(
601
- events.ErrorEvent(error_message=f"Executor error: {e!s}", can_retry=False)
693
+ events.ErrorEvent(
694
+ error_message=f"Executor error: {e!s}",
695
+ can_retry=False,
696
+ session_id="__app__",
697
+ )
602
698
  )
603
699
 
604
700
  async def stop(self) -> None:
@@ -675,7 +771,16 @@ class Executor:
675
771
  style="red",
676
772
  debug_type=DebugType.EXECUTION,
677
773
  )
678
- await self.context.emit_event(events.ErrorEvent(error_message=f"Operation failed: {e!s}", can_retry=False))
774
+ session_id = getattr(submission.operation, "session_id", None) or getattr(
775
+ submission.operation, "target_session_id", None
776
+ )
777
+ await self.context.emit_event(
778
+ events.ErrorEvent(
779
+ error_message=f"Operation failed: {e!s}",
780
+ can_retry=False,
781
+ session_id=session_id or "__app__",
782
+ )
783
+ )
679
784
  # Set completion event even on error to prevent wait_for_completion from hanging
680
785
  event = self._completion_events.get(submission.id)
681
786
  if event is not None: