klaude-code 2.0.1__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 (160) 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 +10 -1
  14. klaude_code/core/agent.py +9 -62
  15. klaude_code/core/agent_profile.py +284 -0
  16. klaude_code/core/executor.py +343 -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 +107 -155
  20. klaude_code/core/task.py +12 -20
  21. klaude_code/core/tool/__init__.py +5 -19
  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 +42 -44
  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/message.py +3 -11
  59. klaude_code/protocol/model.py +78 -9
  60. klaude_code/protocol/op.py +5 -0
  61. klaude_code/protocol/sub_agent/explore.py +0 -15
  62. klaude_code/protocol/sub_agent/task.py +1 -1
  63. klaude_code/protocol/sub_agent/web.py +1 -17
  64. klaude_code/protocol/tools.py +0 -1
  65. klaude_code/session/session.py +6 -5
  66. klaude_code/skill/assets/create-plan/SKILL.md +76 -0
  67. klaude_code/skill/loader.py +1 -1
  68. klaude_code/skill/system_skills.py +1 -1
  69. klaude_code/tui/__init__.py +8 -0
  70. klaude_code/{command → tui/command}/clear_cmd.py +2 -1
  71. klaude_code/{command → tui/command}/debug_cmd.py +4 -3
  72. klaude_code/{command → tui/command}/export_cmd.py +2 -1
  73. klaude_code/{command → tui/command}/export_online_cmd.py +6 -5
  74. klaude_code/{command → tui/command}/fork_session_cmd.py +10 -9
  75. klaude_code/{command → tui/command}/help_cmd.py +3 -2
  76. klaude_code/{command → tui/command}/model_cmd.py +5 -4
  77. klaude_code/{command → tui/command}/model_select.py +2 -2
  78. klaude_code/{command → tui/command}/prompt_command.py +4 -3
  79. klaude_code/{command → tui/command}/refresh_cmd.py +3 -1
  80. klaude_code/{command → tui/command}/registry.py +16 -6
  81. klaude_code/{command → tui/command}/release_notes_cmd.py +3 -2
  82. klaude_code/{command → tui/command}/resume_cmd.py +6 -5
  83. klaude_code/{command → tui/command}/status_cmd.py +4 -3
  84. klaude_code/{command → tui/command}/terminal_setup_cmd.py +4 -3
  85. klaude_code/{command → tui/command}/thinking_cmd.py +4 -3
  86. klaude_code/tui/commands.py +164 -0
  87. klaude_code/{ui/renderers → tui/components}/assistant.py +3 -3
  88. klaude_code/{ui/renderers → tui/components}/bash_syntax.py +2 -2
  89. klaude_code/{ui/renderers → tui/components}/common.py +1 -1
  90. klaude_code/tui/components/developer.py +231 -0
  91. klaude_code/{ui/renderers → tui/components}/diffs.py +2 -2
  92. klaude_code/{ui/renderers → tui/components}/errors.py +2 -2
  93. klaude_code/{ui/renderers → tui/components}/metadata.py +34 -21
  94. klaude_code/{ui → tui/components}/rich/markdown.py +78 -34
  95. klaude_code/{ui → tui/components}/rich/status.py +2 -2
  96. klaude_code/{ui → tui/components}/rich/theme.py +12 -5
  97. klaude_code/{ui/renderers → tui/components}/sub_agent.py +23 -43
  98. klaude_code/{ui/renderers → tui/components}/thinking.py +3 -3
  99. klaude_code/{ui/renderers → tui/components}/tools.py +11 -48
  100. klaude_code/{ui/renderers → tui/components}/user_input.py +3 -20
  101. klaude_code/tui/display.py +85 -0
  102. klaude_code/{ui/modes/repl → tui/input}/__init__.py +1 -1
  103. klaude_code/{ui/modes/repl → tui/input}/completers.py +1 -1
  104. klaude_code/{ui/modes/repl/input_prompt_toolkit.py → tui/input/prompt_toolkit.py} +11 -7
  105. klaude_code/tui/machine.py +606 -0
  106. klaude_code/tui/renderer.py +707 -0
  107. klaude_code/tui/runner.py +321 -0
  108. klaude_code/tui/terminal/__init__.py +56 -0
  109. klaude_code/{ui → tui}/terminal/color.py +1 -1
  110. klaude_code/{ui → tui}/terminal/control.py +1 -1
  111. klaude_code/{ui → tui}/terminal/notifier.py +1 -1
  112. klaude_code/{ui → tui}/terminal/selector.py +36 -17
  113. klaude_code/ui/__init__.py +6 -50
  114. klaude_code/ui/core/display.py +3 -3
  115. klaude_code/ui/core/input.py +2 -1
  116. klaude_code/ui/{modes/debug/display.py → debug_mode.py} +1 -1
  117. klaude_code/ui/{modes/exec/display.py → exec_mode.py} +1 -4
  118. klaude_code/ui/terminal/__init__.py +6 -54
  119. klaude_code/ui/terminal/title.py +31 -0
  120. klaude_code/update.py +163 -0
  121. {klaude_code-2.0.1.dist-info → klaude_code-2.1.0.dist-info}/METADATA +1 -1
  122. klaude_code-2.1.0.dist-info/RECORD +235 -0
  123. klaude_code/cli/runtime.py +0 -525
  124. klaude_code/core/prompt.py +0 -108
  125. klaude_code/core/tool/file/move_tool.md +0 -41
  126. klaude_code/core/tool/file/move_tool.py +0 -435
  127. klaude_code/core/tool/tool_context.py +0 -148
  128. klaude_code/protocol/events.py +0 -194
  129. klaude_code/skill/assets/dev-docs/SKILL.md +0 -108
  130. klaude_code/trace/__init__.py +0 -21
  131. klaude_code/ui/core/stage_manager.py +0 -48
  132. klaude_code/ui/modes/__init__.py +0 -1
  133. klaude_code/ui/modes/debug/__init__.py +0 -1
  134. klaude_code/ui/modes/exec/__init__.py +0 -1
  135. klaude_code/ui/modes/repl/display.py +0 -61
  136. klaude_code/ui/modes/repl/event_handler.py +0 -634
  137. klaude_code/ui/modes/repl/renderer.py +0 -463
  138. klaude_code/ui/renderers/developer.py +0 -215
  139. klaude_code/ui/utils/__init__.py +0 -1
  140. klaude_code-2.0.1.dist-info/RECORD +0 -229
  141. /klaude_code/{trace/log.py → log.py} +0 -0
  142. /klaude_code/{command → tui/command}/__init__.py +0 -0
  143. /klaude_code/{command → tui/command}/command_abc.py +0 -0
  144. /klaude_code/{command → tui/command}/prompt-commit.md +0 -0
  145. /klaude_code/{command → tui/command}/prompt-init.md +0 -0
  146. /klaude_code/{ui/renderers → tui/components}/__init__.py +0 -0
  147. /klaude_code/{ui/renderers → tui/components}/mermaid_viewer.py +0 -0
  148. /klaude_code/{ui → tui/components}/rich/__init__.py +0 -0
  149. /klaude_code/{ui → tui/components}/rich/cjk_wrap.py +0 -0
  150. /klaude_code/{ui → tui/components}/rich/code_panel.py +0 -0
  151. /klaude_code/{ui → tui/components}/rich/live.py +0 -0
  152. /klaude_code/{ui → tui/components}/rich/quote.py +0 -0
  153. /klaude_code/{ui → tui/components}/rich/searchable_text.py +0 -0
  154. /klaude_code/{ui/modes/repl → tui/input}/clipboard.py +0 -0
  155. /klaude_code/{ui/modes/repl → tui/input}/key_bindings.py +0 -0
  156. /klaude_code/{ui → tui}/terminal/image.py +0 -0
  157. /klaude_code/{ui → tui}/terminal/progress_bar.py +0 -0
  158. /klaude_code/ui/{utils/common.py → common.py} +0 -0
  159. {klaude_code-2.0.1.dist-info → klaude_code-2.1.0.dist-info}/WHEEL +0 -0
  160. {klaude_code-2.0.1.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,94 +146,44 @@ 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)
182
- if existing_active is not None and not existing_active.task.done():
183
- raise RuntimeError(f"Active task already registered for operation {operation.id}")
184
- task: asyncio.Task[None] = asyncio.create_task(
185
- self._run_agent_task(agent, operation.input, operation.id, operation.session_id)
186
- )
187
- self.task_manager.register(operation.id, task, operation.session_id)
188
-
189
- async def handle_change_model(self, operation: op.ChangeModelOperation) -> None:
190
- agent = await self._ensure_agent(operation.session_id)
191
- config = load_config()
192
-
193
- llm_config = config.get_model_config(operation.model_name)
194
- llm_client = create_llm_client(llm_config)
195
- agent.set_model_profile(self.model_profile_provider.build_profile(llm_client))
196
-
197
- agent.session.model_config_name = operation.model_name
198
- agent.session.model_thinking = llm_config.thinking
199
-
200
- if operation.save_as_default:
201
- config.main_model = operation.model_name
202
- await config.save()
152
+ async def run_agent(self, operation: op.RunAgentOperation) -> None:
153
+ agent = await self.ensure_agent(operation.session_id)
203
154
 
204
- if operation.emit_switch_message:
205
- default_note = " (saved as default)" if operation.save_as_default else ""
206
- developer_item = message.DeveloperMessage(
207
- parts=message.text_parts_from_str(f"Switched to: {llm_config.model}{default_note}"),
208
- command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
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
+ )
209
162
  )
210
- agent.session.append_history([developer_item])
211
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
212
-
213
- if self._on_model_change is not None:
214
- self._on_model_change(llm_client.model_name)
215
-
216
- if operation.emit_welcome_event:
217
- await self.emit_event(events.WelcomeEvent(llm_config=llm_config, work_dir=str(agent.session.work_dir)))
218
-
219
- async def handle_change_thinking(self, operation: op.ChangeThinkingOperation) -> None:
220
- """Handle a change thinking operation.
221
-
222
- Interactive thinking selection must happen in the UI/CLI layer. Core only
223
- applies a concrete thinking configuration.
224
- """
225
- agent = await self._ensure_agent(operation.session_id)
226
-
227
- config = agent.profile.llm_client.get_llm_config()
228
-
229
- def _format_thinking_for_display(thinking: Thinking | None) -> str:
230
- if thinking is None:
231
- return "not configured"
232
- if thinking.reasoning_effort:
233
- return f"reasoning_effort={thinking.reasoning_effort}"
234
- if thinking.type == "disabled":
235
- return "off"
236
- if thinking.type == "enabled":
237
- if thinking.budget_tokens is None:
238
- return "enabled"
239
- return f"enabled (budget_tokens={thinking.budget_tokens})"
240
- return "not set"
241
-
242
- if operation.thinking is None:
243
- raise ValueError("thinking must be provided; interactive selection belongs to UI")
244
-
245
- current = _format_thinking_for_display(config.thinking)
246
- config.thinking = operation.thinking
247
- agent.session.model_thinking = operation.thinking
248
- new_status = _format_thinking_for_display(config.thinking)
249
163
 
250
- if operation.emit_switch_message:
251
- developer_item = message.DeveloperMessage(
252
- parts=message.text_parts_from_str(f"Thinking changed: {current} -> {new_status}"),
253
- command_output=model.CommandOutput(command_name=commands.CommandName.THINKING),
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
+ ]
254
174
  )
255
- agent.session.append_history([developer_item])
256
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
257
175
 
258
- if operation.emit_welcome_event:
259
- await self.emit_event(events.WelcomeEvent(work_dir=str(agent.session.work_dir), llm_config=config))
176
+ existing_active = self._task_manager.get(operation.id)
177
+ if existing_active is not None and not existing_active.task.done():
178
+ raise RuntimeError(f"Active task already registered for operation {operation.id}")
260
179
 
261
- async def handle_clear_session(self, operation: op.ClearSessionOperation) -> None:
262
- agent = await self._ensure_agent(operation.session_id)
180
+ task: asyncio.Task[None] = asyncio.create_task(
181
+ self._run_agent_task(agent, operation.input, operation.id, operation.session_id)
182
+ )
183
+ self._task_manager.register(operation.id, task, operation.session_id)
184
+
185
+ async def clear_session(self, session_id: str) -> None:
186
+ agent = await self.ensure_agent(session_id)
263
187
  new_session = Session.create(work_dir=agent.session.work_dir)
264
188
  new_session.model_name = agent.session.model_name
265
189
  new_session.model_config_name = agent.session.model_config_name
@@ -268,37 +192,37 @@ class ExecutorContext:
268
192
 
269
193
  developer_item = message.DeveloperMessage(
270
194
  parts=message.text_parts_from_str("started new conversation"),
271
- command_output=model.CommandOutput(command_name=commands.CommandName.CLEAR),
195
+ ui_extra=model.build_command_output_extra(commands.CommandName.CLEAR),
272
196
  )
273
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
274
- await self.emit_event(
197
+ await self._emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
198
+ await self._emit_event(
275
199
  events.WelcomeEvent(
200
+ session_id=agent.session.id,
276
201
  work_dir=str(agent.session.work_dir),
277
- llm_config=self.llm_clients.main.get_llm_config(),
202
+ llm_config=self._llm_clients.main.get_llm_config(),
278
203
  )
279
204
  )
280
205
 
281
- async def handle_resume_session(self, operation: op.ResumeSessionOperation) -> None:
282
- target_session = Session.load(operation.target_session_id)
206
+ async def resume_session(self, target_session_id: str) -> None:
207
+ target_session = Session.load(target_session_id)
283
208
  if (
284
209
  target_session.model_thinking is not None
285
210
  and target_session.model_name
286
- and target_session.model_name == self.llm_clients.main.model_name
211
+ and target_session.model_name == self._llm_clients.main.model_name
287
212
  ):
288
- self.llm_clients.main.get_llm_config().thinking = target_session.model_thinking
289
-
290
- profile = self.model_profile_provider.build_profile(self.llm_clients.main)
291
- from klaude_code.core.agent import Agent
213
+ self._llm_clients.main.get_llm_config().thinking = target_session.model_thinking
292
214
 
215
+ profile = self._model_profile_provider.build_profile(self._llm_clients.main)
293
216
  agent = Agent(session=target_session, profile=profile)
294
217
 
295
218
  async for evt in agent.replay_history():
296
- await self.emit_event(evt)
219
+ await self._emit_event(evt)
297
220
 
298
- await self.emit_event(
221
+ await self._emit_event(
299
222
  events.WelcomeEvent(
223
+ session_id=target_session.id,
300
224
  work_dir=str(target_session.work_dir),
301
- llm_config=self.llm_clients.main.get_llm_config(),
225
+ llm_config=self._llm_clients.main.get_llm_config(),
302
226
  )
303
227
  )
304
228
 
@@ -309,29 +233,38 @@ class ExecutorContext:
309
233
  debug_type=DebugType.EXECUTION,
310
234
  )
311
235
 
312
- async def handle_export_session(self, operation: op.ExportSessionOperation) -> None:
313
- agent = await self._ensure_agent(operation.session_id)
314
- try:
315
- output_path = self._resolve_export_output_path(operation.output_path, agent.session)
316
- html_doc = self._build_export_html(agent)
317
- await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
318
- await asyncio.to_thread(output_path.write_text, html_doc, "utf-8")
319
- await asyncio.to_thread(self._open_file, output_path)
320
- developer_item = message.DeveloperMessage(
321
- parts=message.text_parts_from_str(f"Session exported and opened: {output_path}"),
322
- command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT),
323
- )
324
- agent.session.append_history([developer_item])
325
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
326
- except Exception as exc: # pragma: no cover
327
- import traceback
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 []
328
242
 
329
- developer_item = message.DeveloperMessage(
330
- parts=message.text_parts_from_str(f"Failed to export session: {exc}\n{traceback.format_exc()}"),
331
- command_output=model.CommandOutput(command_name=commands.CommandName.EXPORT, is_error=True),
332
- )
333
- agent.session.append_history([developer_item])
334
- await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
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)
335
268
 
336
269
  async def _run_agent_task(
337
270
  self,
@@ -347,15 +280,14 @@ class ExecutorContext:
347
280
  debug_type=DebugType.EXECUTION,
348
281
  )
349
282
 
350
- async def _runner(state: model.SubAgentState) -> SubAgentResult:
351
- return await self.sub_agent_manager.run_sub_agent(agent, state)
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)
352
288
 
353
- token = current_run_subtask_callback.set(_runner)
354
- try:
355
- async for event in agent.run_task(user_input):
356
- await self.emit_event(event)
357
- finally:
358
- current_run_subtask_callback.reset(token)
289
+ async for event in agent.run_task(user_input, run_subtask=_runner):
290
+ await self._emit_event(event)
359
291
 
360
292
  except asyncio.CancelledError:
361
293
  log_debug(
@@ -363,7 +295,7 @@ class ExecutorContext:
363
295
  style="yellow",
364
296
  debug_type=DebugType.EXECUTION,
365
297
  )
366
- await self.emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
298
+ await self._emit_event(events.TaskFinishEvent(session_id=session_id, task_result="task cancelled"))
367
299
 
368
300
  except Exception as e:
369
301
  import traceback
@@ -374,20 +306,234 @@ class ExecutorContext:
374
306
  debug_type=DebugType.EXECUTION,
375
307
  )
376
308
  log_debug(traceback.format_exc(), style="red", debug_type=DebugType.EXECUTION)
377
- await self.emit_event(
309
+ await self._emit_event(
378
310
  events.ErrorEvent(
379
311
  error_message=f"Agent task failed: [{e.__class__.__name__}] {e!s} {traceback.format_exc()}",
380
312
  can_retry=False,
313
+ session_id=session_id,
381
314
  )
382
315
  )
383
316
  finally:
384
- self.task_manager.remove(task_id)
317
+ self._task_manager.remove(task_id)
385
318
  log_debug(
386
319
  f"Cleaned up agent task {task_id}",
387
320
  style="cyan",
388
321
  debug_type=DebugType.EXECUTION,
389
322
  )
390
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)
348
+ llm_client = create_llm_client(llm_config)
349
+ agent.set_model_profile(self._model_profile_provider.build_profile(llm_client))
350
+
351
+ agent.session.model_config_name = model_name
352
+ agent.session.model_thinking = llm_config.thinking
353
+
354
+ if save_as_default:
355
+ config.main_model = model_name
356
+ await config.save()
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
+
439
+ if operation.emit_switch_message:
440
+ default_note = " (saved as default)" if operation.save_as_default else ""
441
+ developer_item = message.DeveloperMessage(
442
+ parts=message.text_parts_from_str(f"Switched to: {llm_config.model}{default_note}"),
443
+ ui_extra=model.build_command_output_extra(commands.CommandName.MODEL),
444
+ )
445
+ agent.session.append_history([developer_item])
446
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
447
+
448
+ if self._on_model_change is not None:
449
+ self._on_model_change(llm_client_name)
450
+
451
+ if operation.emit_welcome_event:
452
+ await self.emit_event(
453
+ events.WelcomeEvent(
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,
458
+ )
459
+ )
460
+
461
+ async def handle_change_thinking(self, operation: op.ChangeThinkingOperation) -> None:
462
+ """Handle a change thinking operation.
463
+
464
+ Interactive thinking selection must happen in the UI/CLI layer. Core only
465
+ applies a concrete thinking configuration.
466
+ """
467
+ agent = await self._agent_runtime.ensure_agent(operation.session_id)
468
+
469
+ def _format_thinking_for_display(thinking: Thinking | None) -> str:
470
+ if thinking is None:
471
+ return "not configured"
472
+ if thinking.reasoning_effort:
473
+ return f"reasoning_effort={thinking.reasoning_effort}"
474
+ if thinking.type == "disabled":
475
+ return "off"
476
+ if thinking.type == "enabled":
477
+ if thinking.budget_tokens is None:
478
+ return "enabled"
479
+ return f"enabled (budget_tokens={thinking.budget_tokens})"
480
+ return "not set"
481
+
482
+ if operation.thinking is None:
483
+ raise ValueError("thinking must be provided; interactive selection belongs to UI")
484
+
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)
488
+
489
+ if operation.emit_switch_message:
490
+ developer_item = message.DeveloperMessage(
491
+ parts=message.text_parts_from_str(f"Thinking changed: {current} -> {new_status}"),
492
+ ui_extra=model.build_command_output_extra(commands.CommandName.THINKING),
493
+ )
494
+ agent.session.append_history([developer_item])
495
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
496
+
497
+ if operation.emit_welcome_event:
498
+ await self.emit_event(
499
+ events.WelcomeEvent(
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,
504
+ )
505
+ )
506
+
507
+ async def handle_clear_session(self, operation: op.ClearSessionOperation) -> None:
508
+ await self._agent_runtime.clear_session(operation.session_id)
509
+
510
+ async def handle_resume_session(self, operation: op.ResumeSessionOperation) -> None:
511
+ await self._agent_runtime.resume_session(operation.target_session_id)
512
+
513
+ async def handle_export_session(self, operation: op.ExportSessionOperation) -> None:
514
+ agent = await self._agent_runtime.ensure_agent(operation.session_id)
515
+ try:
516
+ output_path = self._resolve_export_output_path(operation.output_path, agent.session)
517
+ html_doc = self._build_export_html(agent)
518
+ await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
519
+ await asyncio.to_thread(output_path.write_text, html_doc, "utf-8")
520
+ await asyncio.to_thread(self._open_file, output_path)
521
+ developer_item = message.DeveloperMessage(
522
+ parts=message.text_parts_from_str(f"Session exported and opened: {output_path}"),
523
+ ui_extra=model.build_command_output_extra(commands.CommandName.EXPORT),
524
+ )
525
+ agent.session.append_history([developer_item])
526
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
527
+ except Exception as exc: # pragma: no cover
528
+ import traceback
529
+
530
+ developer_item = message.DeveloperMessage(
531
+ parts=message.text_parts_from_str(f"Failed to export session: {exc}\n{traceback.format_exc()}"),
532
+ ui_extra=model.build_command_output_extra(commands.CommandName.EXPORT, is_error=True),
533
+ )
534
+ agent.session.append_history([developer_item])
535
+ await self.emit_event(events.DeveloperMessageEvent(session_id=agent.session.id, item=developer_item))
536
+
391
537
  def _resolve_export_output_path(self, raw: str | None, session: Session) -> Path:
392
538
  trimmed = (raw or "").strip()
393
539
  if trimmed:
@@ -432,43 +578,7 @@ class ExecutorContext:
432
578
  async def handle_interrupt(self, operation: op.InterruptOperation) -> None:
433
579
  """Handle an interrupt by invoking agent.cancel() and cancelling tasks."""
434
580
 
435
- # Determine affected sessions
436
- if operation.target_session_id is not None:
437
- session_ids: list[str] = [operation.target_session_id]
438
- else:
439
- agent = self._agent
440
- session_ids = [agent.session.id] if agent is not None else []
441
-
442
- # Call cancel() on each affected agent to persist an interrupt marker
443
- for sid in session_ids:
444
- agent = self._get_active_agent(sid)
445
- if agent is not None:
446
- for evt in agent.cancel():
447
- await self.emit_event(evt)
448
-
449
- # emit interrupt event
450
- await self.emit_event(events.InterruptEvent(session_id=operation.target_session_id or "all"))
451
-
452
- # Find tasks to cancel (filter by target sessions if provided)
453
- if operation.target_session_id is None:
454
- session_filter: set[str] | None = None
455
- else:
456
- session_filter = {operation.target_session_id}
457
-
458
- tasks_to_cancel = self.task_manager.cancel_tasks_for_sessions(session_filter)
459
-
460
- scope = operation.target_session_id or "all"
461
- log_debug(
462
- f"Interrupting {len(tasks_to_cancel)} task(s) for: {scope}",
463
- style="yellow",
464
- debug_type=DebugType.EXECUTION,
465
- )
466
-
467
- # Cancel the tasks
468
- for task_id, task in tasks_to_cancel:
469
- task.cancel()
470
- # Remove from active tasks immediately
471
- self.task_manager.remove(task_id)
581
+ await self._agent_runtime.interrupt(operation.target_session_id)
472
582
 
473
583
  def get_active_task(self, submission_id: str) -> asyncio.Task[None] | None:
474
584
  """Return the asyncio.Task for a submission id if one is registered."""
@@ -483,16 +593,6 @@ class ExecutorContext:
483
593
 
484
594
  return self.task_manager.get(submission_id) is not None
485
595
 
486
- def _get_active_agent(self, session_id: str) -> Agent | None:
487
- """Return the active agent if its session id matches ``session_id``."""
488
-
489
- agent = self._agent
490
- if agent is None:
491
- return None
492
- if agent.session.id != session_id:
493
- return None
494
- return agent
495
-
496
596
 
497
597
  class Executor:
498
598
  """
@@ -590,7 +690,11 @@ class Executor:
590
690
  debug_type=DebugType.EXECUTION,
591
691
  )
592
692
  await self.context.emit_event(
593
- 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
+ )
594
698
  )
595
699
 
596
700
  async def stop(self) -> None:
@@ -667,7 +771,16 @@ class Executor:
667
771
  style="red",
668
772
  debug_type=DebugType.EXECUTION,
669
773
  )
670
- 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
+ )
671
784
  # Set completion event even on error to prevent wait_for_completion from hanging
672
785
  event = self._completion_events.get(submission.id)
673
786
  if event is not None: