klaude-code 1.2.8__py3-none-any.whl → 1.2.10__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 (82) hide show
  1. klaude_code/auth/codex/__init__.py +1 -1
  2. klaude_code/cli/main.py +12 -1
  3. klaude_code/cli/runtime.py +7 -11
  4. klaude_code/command/__init__.py +68 -21
  5. klaude_code/command/clear_cmd.py +6 -2
  6. klaude_code/command/command_abc.py +5 -2
  7. klaude_code/command/diff_cmd.py +5 -2
  8. klaude_code/command/export_cmd.py +7 -4
  9. klaude_code/command/help_cmd.py +6 -2
  10. klaude_code/command/model_cmd.py +5 -2
  11. klaude_code/command/prompt-deslop.md +14 -0
  12. klaude_code/command/prompt_command.py +8 -3
  13. klaude_code/command/refresh_cmd.py +6 -2
  14. klaude_code/command/registry.py +17 -5
  15. klaude_code/command/release_notes_cmd.py +89 -0
  16. klaude_code/command/status_cmd.py +98 -56
  17. klaude_code/command/terminal_setup_cmd.py +7 -4
  18. klaude_code/const/__init__.py +1 -1
  19. klaude_code/core/agent.py +66 -26
  20. klaude_code/core/executor.py +2 -2
  21. klaude_code/core/manager/agent_manager.py +6 -7
  22. klaude_code/core/manager/llm_clients.py +47 -22
  23. klaude_code/core/manager/llm_clients_builder.py +19 -7
  24. klaude_code/core/manager/sub_agent_manager.py +6 -2
  25. klaude_code/core/prompt.py +38 -28
  26. klaude_code/core/reminders.py +4 -7
  27. klaude_code/core/task.py +59 -40
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/_utils.py +30 -0
  30. klaude_code/core/tool/file/apply_patch_tool.py +1 -1
  31. klaude_code/core/tool/file/edit_tool.py +6 -31
  32. klaude_code/core/tool/file/multi_edit_tool.py +7 -32
  33. klaude_code/core/tool/file/read_tool.py +6 -18
  34. klaude_code/core/tool/file/write_tool.py +6 -31
  35. klaude_code/core/tool/memory/__init__.py +5 -0
  36. klaude_code/core/tool/memory/memory_tool.py +2 -2
  37. klaude_code/core/tool/memory/skill_loader.py +2 -1
  38. klaude_code/core/tool/memory/skill_tool.py +13 -0
  39. klaude_code/core/tool/sub_agent_tool.py +2 -1
  40. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  41. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  42. klaude_code/core/tool/tool_context.py +21 -4
  43. klaude_code/core/tool/tool_runner.py +5 -8
  44. klaude_code/core/tool/web/mermaid_tool.py +1 -4
  45. klaude_code/core/turn.py +40 -37
  46. klaude_code/llm/__init__.py +2 -12
  47. klaude_code/llm/anthropic/client.py +14 -44
  48. klaude_code/llm/client.py +2 -2
  49. klaude_code/llm/codex/client.py +4 -3
  50. klaude_code/llm/input_common.py +0 -6
  51. klaude_code/llm/openai_compatible/client.py +31 -74
  52. klaude_code/llm/openai_compatible/input.py +6 -4
  53. klaude_code/llm/openai_compatible/stream_processor.py +82 -0
  54. klaude_code/llm/openrouter/client.py +32 -62
  55. klaude_code/llm/openrouter/input.py +4 -27
  56. klaude_code/llm/registry.py +33 -7
  57. klaude_code/llm/responses/client.py +16 -48
  58. klaude_code/llm/responses/input.py +1 -1
  59. klaude_code/llm/usage.py +61 -11
  60. klaude_code/protocol/commands.py +1 -0
  61. klaude_code/protocol/events.py +11 -2
  62. klaude_code/protocol/model.py +147 -24
  63. klaude_code/protocol/op.py +1 -0
  64. klaude_code/protocol/sub_agent.py +5 -1
  65. klaude_code/session/export.py +56 -32
  66. klaude_code/session/session.py +43 -21
  67. klaude_code/session/templates/export_session.html +4 -1
  68. klaude_code/ui/core/input.py +1 -1
  69. klaude_code/ui/modes/repl/__init__.py +1 -5
  70. klaude_code/ui/modes/repl/clipboard.py +5 -5
  71. klaude_code/ui/modes/repl/event_handler.py +153 -54
  72. klaude_code/ui/modes/repl/renderer.py +4 -4
  73. klaude_code/ui/renderers/developer.py +35 -25
  74. klaude_code/ui/renderers/metadata.py +68 -30
  75. klaude_code/ui/renderers/tools.py +53 -87
  76. klaude_code/ui/rich/markdown.py +5 -5
  77. klaude_code/ui/terminal/control.py +2 -2
  78. klaude_code/version.py +3 -3
  79. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/METADATA +1 -1
  80. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/RECORD +82 -78
  81. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/WHEEL +0 -0
  82. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/entry_points.txt +0 -0
@@ -26,9 +26,34 @@ PROMPT_FILES: dict[str, str] = {
26
26
 
27
27
 
28
28
  @lru_cache(maxsize=None)
29
- def get_system_prompt(model_name: str, sub_agent_type: str | None = None) -> str:
30
- """Get system prompt content for the given model and sub-agent type."""
29
+ def _load_base_prompt(file_key: str) -> str:
30
+ """Load and cache the base prompt content from file."""
31
+ try:
32
+ prompt_path = PROMPT_FILES[file_key]
33
+ except KeyError as exc:
34
+ raise ValueError(f"Unknown prompt key: {file_key}") from exc
35
+
36
+ return files(__package__).joinpath(prompt_path).read_text(encoding="utf-8").strip()
31
37
 
38
+
39
+ def _get_file_key(model_name: str, sub_agent_type: str | None) -> str:
40
+ """Determine which prompt file to use based on model and agent type."""
41
+ if sub_agent_type is not None:
42
+ return sub_agent_type
43
+
44
+ match model_name:
45
+ case "gpt-5.1-codex-max":
46
+ return "main_gpt_5_1_codex_max"
47
+ case name if "gpt-5" in name:
48
+ return "main_gpt_5_1"
49
+ case name if "gemini" in name:
50
+ return "main_gemini"
51
+ case _:
52
+ return "main_claude"
53
+
54
+
55
+ def _build_env_info(model_name: str) -> str:
56
+ """Build environment info section with dynamic runtime values."""
32
57
  cwd = Path.cwd()
33
58
  today = datetime.datetime.now().strftime("%Y-%m-%d")
34
59
  is_git_repo = (cwd / ".git").exists()
@@ -38,30 +63,6 @@ def get_system_prompt(model_name: str, sub_agent_type: str | None = None) -> str
38
63
  if shutil.which(command) is not None:
39
64
  available_tools.append(f"{command}: {desc}")
40
65
 
41
- if sub_agent_type is None:
42
- match model_name:
43
- case "gpt-5.1-codex-max":
44
- file_key = "main_gpt_5_1_codex_max"
45
- case name if "gpt-5" in name:
46
- file_key = "main_gpt_5_1"
47
- case name if "gemini" in name:
48
- file_key = "main_gemini"
49
- case _:
50
- file_key = "main_claude"
51
- else:
52
- file_key = sub_agent_type
53
-
54
- try:
55
- prompt_path = PROMPT_FILES[file_key]
56
- except KeyError as exc:
57
- raise ValueError(f"Unknown prompt key: {file_key}") from exc
58
-
59
- base_prompt = files(__package__).joinpath(prompt_path).read_text(encoding="utf-8").strip()
60
-
61
- if model_name == "gpt-5.1-codex-max":
62
- # Do not add env info for gpt-5.1-codex-max
63
- return base_prompt
64
-
65
66
  env_lines: list[str] = [
66
67
  "",
67
68
  "",
@@ -80,6 +81,15 @@ def get_system_prompt(model_name: str, sub_agent_type: str | None = None) -> str
80
81
 
81
82
  env_lines.append("</env>")
82
83
 
83
- env_info = "\n".join(env_lines)
84
+ return "\n".join(env_lines)
85
+
86
+
87
+ def get_system_prompt(model_name: str, sub_agent_type: str | None = None) -> str:
88
+ """Get system prompt content for the given model and sub-agent type."""
89
+ file_key = _get_file_key(model_name, sub_agent_type)
90
+ base_prompt = _load_base_prompt(file_key)
91
+
92
+ if model_name == "gpt-5.1-codex-max":
93
+ return base_prompt
84
94
 
85
- return base_prompt + env_info
95
+ return base_prompt + _build_env_info(model_name)
@@ -150,16 +150,16 @@ async def todo_not_used_recently_reminder(
150
150
  return None
151
151
 
152
152
  # Count non-todo tool calls since the last TodoWrite
153
- other_tool_call_count_befor_last_todo = 0
153
+ other_tool_call_count_before_last_todo = 0
154
154
  for item in reversed(session.conversation_history):
155
155
  if isinstance(item, model.ToolCallItem):
156
156
  if item.name in (tools.TODO_WRITE, tools.UPDATE_PLAN):
157
157
  break
158
- other_tool_call_count_befor_last_todo += 1
159
- if other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD:
158
+ other_tool_call_count_before_last_todo += 1
159
+ if other_tool_call_count_before_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD:
160
160
  break
161
161
 
162
- not_used_recently = other_tool_call_count_befor_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD
162
+ not_used_recently = other_tool_call_count_before_last_todo >= const.TODO_REMINDER_TOOL_CALL_THRESHOLD
163
163
 
164
164
  if not not_used_recently:
165
165
  return None
@@ -344,9 +344,6 @@ async def last_path_memory_reminder(
344
344
  paths.append(path)
345
345
  except json.JSONDecodeError:
346
346
  continue
347
- elif tool_call.name == tools.BASH:
348
- # TODO: haiku check file path
349
- pass
350
347
  paths = list(set(paths))
351
348
  memories: list[Memory] = []
352
349
  if len(paths) == 0:
klaude_code/core/task.py CHANGED
@@ -25,28 +25,30 @@ class MetadataAccumulator:
25
25
  """
26
26
 
27
27
  def __init__(self, model_name: str) -> None:
28
- self._accumulated = model.ResponseMetadataItem(model_name=model_name)
28
+ self._main = model.TaskMetadata(model_name=model_name)
29
+ self._sub_agent_metadata: list[model.TaskMetadata] = []
29
30
  self._throughput_weighted_sum: float = 0.0
30
31
  self._throughput_tracked_tokens: int = 0
31
32
 
32
33
  def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
33
34
  """Merge a turn's metadata into the accumulated state."""
34
- accumulated = self._accumulated
35
+ main = self._main
35
36
  usage = turn_metadata.usage
36
37
 
37
38
  if usage is not None:
38
- if accumulated.usage is None:
39
- accumulated.usage = model.Usage()
40
- acc_usage = accumulated.usage
39
+ if main.usage is None:
40
+ main.usage = model.Usage()
41
+ acc_usage = main.usage
41
42
  acc_usage.input_tokens += usage.input_tokens
42
43
  acc_usage.cached_tokens += usage.cached_tokens
43
44
  acc_usage.reasoning_tokens += usage.reasoning_tokens
44
45
  acc_usage.output_tokens += usage.output_tokens
45
- acc_usage.total_tokens += usage.total_tokens
46
46
  acc_usage.currency = usage.currency
47
47
 
48
- if usage.context_usage_percent is not None:
49
- acc_usage.context_usage_percent = usage.context_usage_percent
48
+ if usage.context_token is not None:
49
+ acc_usage.context_token = usage.context_token
50
+ if usage.context_limit is not None:
51
+ acc_usage.context_limit = usage.context_limit
50
52
 
51
53
  if usage.first_token_latency_ms is not None:
52
54
  if acc_usage.first_token_latency_ms is None:
@@ -63,47 +65,56 @@ class MetadataAccumulator:
63
65
  self._throughput_weighted_sum += usage.throughput_tps * current_output
64
66
  self._throughput_tracked_tokens += current_output
65
67
 
66
- # Accumulate costs
67
68
  if usage.input_cost is not None:
68
69
  acc_usage.input_cost = (acc_usage.input_cost or 0.0) + usage.input_cost
69
70
  if usage.output_cost is not None:
70
71
  acc_usage.output_cost = (acc_usage.output_cost or 0.0) + usage.output_cost
71
72
  if usage.cache_read_cost is not None:
72
73
  acc_usage.cache_read_cost = (acc_usage.cache_read_cost or 0.0) + usage.cache_read_cost
73
- if usage.total_cost is not None:
74
- acc_usage.total_cost = (acc_usage.total_cost or 0.0) + usage.total_cost
75
74
 
76
75
  if turn_metadata.provider is not None:
77
- accumulated.provider = turn_metadata.provider
76
+ main.provider = turn_metadata.provider
78
77
  if turn_metadata.model_name:
79
- accumulated.model_name = turn_metadata.model_name
80
- if turn_metadata.response_id:
81
- accumulated.response_id = turn_metadata.response_id
78
+ main.model_name = turn_metadata.model_name
82
79
 
83
- def finalize(self, task_duration_s: float) -> model.ResponseMetadataItem:
80
+ def add_sub_agent_metadata(self, sub_agent_metadata: model.TaskMetadata) -> None:
81
+ """Add sub-agent task metadata to the accumulated state."""
82
+ self._sub_agent_metadata.append(sub_agent_metadata)
83
+
84
+ def finalize(self, task_duration_s: float) -> model.TaskMetadataItem:
84
85
  """Return the final accumulated metadata with computed throughput and duration."""
85
- accumulated = self._accumulated
86
- if accumulated.usage is not None:
86
+ main = self._main
87
+ if main.usage is not None:
87
88
  if self._throughput_tracked_tokens > 0:
88
- accumulated.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
89
+ main.usage.throughput_tps = self._throughput_weighted_sum / self._throughput_tracked_tokens
89
90
  else:
90
- accumulated.usage.throughput_tps = None
91
+ main.usage.throughput_tps = None
91
92
 
92
- accumulated.task_duration_s = task_duration_s
93
- return accumulated
93
+ main.task_duration_s = task_duration_s
94
+ return model.TaskMetadataItem(main=main, sub_agent_task_metadata=self._sub_agent_metadata)
94
95
 
95
96
 
96
97
  @dataclass
97
- class TaskExecutionContext:
98
- """Execution context required to run a task."""
98
+ class SessionContext:
99
+ """Shared session-level context for task and turn execution.
100
+
101
+ Contains common fields that both TaskExecutionContext and TurnExecutionContext need.
102
+ """
99
103
 
100
104
  session_id: str
101
- profile: AgentProfile
102
105
  get_conversation_history: Callable[[], list[model.ConversationItem]]
103
106
  append_history: Callable[[Sequence[model.ConversationItem]], None]
104
- tool_registry: dict[str, type[ToolABC]]
105
107
  file_tracker: MutableMapping[str, float]
106
108
  todo_context: TodoContext
109
+
110
+
111
+ @dataclass
112
+ class TaskExecutionContext:
113
+ """Execution context required to run a task."""
114
+
115
+ session_ctx: SessionContext
116
+ profile: AgentProfile
117
+ tool_registry: dict[str, type[ToolABC]]
107
118
  # For reminder processing - needs access to session
108
119
  process_reminder: Callable[[Reminder], AsyncGenerator[events.DeveloperMessageEvent, None]]
109
120
  sub_agent_state: model.SubAgentState | None
@@ -135,18 +146,18 @@ class TaskExecutor:
135
146
  async def run(self, user_input: model.UserInputPayload) -> AsyncGenerator[events.Event, None]:
136
147
  """Execute the task, yielding events as they occur."""
137
148
  ctx = self._context
149
+ session_ctx = ctx.session_ctx
138
150
  self._started_at = time.perf_counter()
139
151
 
140
152
  yield events.TaskStartEvent(
141
- session_id=ctx.session_id,
153
+ session_id=session_ctx.session_id,
142
154
  sub_agent_state=ctx.sub_agent_state,
143
155
  )
144
156
 
145
- ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
157
+ session_ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
146
158
 
147
159
  profile = ctx.profile
148
160
  metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
149
- last_assistant_message: events.AssistantMessageEvent | None = None
150
161
 
151
162
  while True:
152
163
  # Process reminders at the start of each turn
@@ -155,15 +166,11 @@ class TaskExecutor:
155
166
  yield event
156
167
 
157
168
  turn_context = TurnExecutionContext(
158
- session_id=ctx.session_id,
159
- get_conversation_history=ctx.get_conversation_history,
160
- append_history=ctx.append_history,
169
+ session_ctx=session_ctx,
161
170
  llm_client=profile.llm_client,
162
171
  system_prompt=profile.system_prompt,
163
172
  tools=profile.tools,
164
173
  tool_registry=ctx.tool_registry,
165
- file_tracker=ctx.file_tracker,
166
- todo_context=ctx.todo_context,
167
174
  )
168
175
 
169
176
  turn: TurnExecutor | None = None
@@ -178,11 +185,14 @@ class TaskExecutor:
178
185
  async for turn_event in turn.run():
179
186
  match turn_event:
180
187
  case events.AssistantMessageEvent() as am:
181
- if am.content.strip() != "":
182
- last_assistant_message = am
183
188
  yield am
184
189
  case events.ResponseMetadataEvent() as e:
185
190
  metadata_accumulator.add(e.metadata)
191
+ case events.ToolResultEvent() as e:
192
+ # Collect sub-agent task metadata from tool results
193
+ if e.task_metadata is not None:
194
+ metadata_accumulator.add_sub_agent_metadata(e.task_metadata)
195
+ yield turn_event
186
196
  case _:
187
197
  yield turn_event
188
198
 
@@ -219,14 +229,23 @@ class TaskExecutor:
219
229
  task_duration_s = time.perf_counter() - self._started_at
220
230
  accumulated = metadata_accumulator.finalize(task_duration_s)
221
231
 
222
- yield events.ResponseMetadataEvent(metadata=accumulated, session_id=ctx.session_id)
223
- ctx.append_history([accumulated])
232
+ yield events.TaskMetadataEvent(metadata=accumulated, session_id=session_ctx.session_id)
233
+ session_ctx.append_history([accumulated])
224
234
  yield events.TaskFinishEvent(
225
- session_id=ctx.session_id,
226
- task_result=last_assistant_message.content if last_assistant_message else "",
235
+ session_id=session_ctx.session_id,
236
+ task_result=_get_last_assistant_message(session_ctx.get_conversation_history()) or "",
227
237
  )
228
238
 
229
239
 
240
+ def _get_last_assistant_message(history: list[model.ConversationItem]) -> str | None:
241
+ """Return the content of the most recent assistant message in history."""
242
+
243
+ for item in reversed(history):
244
+ if isinstance(item, model.AssistantMessageItem):
245
+ return item.content or ""
246
+ return None
247
+
248
+
230
249
  def _retry_delay_seconds(attempt: int) -> float:
231
250
  """Compute exponential backoff delay for the given attempt count."""
232
251
  capped_attempt = max(1, attempt)
@@ -16,6 +16,7 @@ from .tool_abc import ToolABC
16
16
  from .tool_context import (
17
17
  TodoContext,
18
18
  ToolContextToken,
19
+ build_todo_context,
19
20
  current_run_subtask_callback,
20
21
  reset_tool_context,
21
22
  set_tool_context_from_session,
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "ToolABC",
47
48
  # Tool context
48
49
  "TodoContext",
50
+ "build_todo_context",
49
51
  "ToolContextToken",
50
52
  "current_run_subtask_callback",
51
53
  "reset_tool_context",
@@ -0,0 +1,30 @@
1
+ """Shared utility functions for file tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def is_directory(path: str) -> bool:
10
+ """Check if path is a directory."""
11
+ return os.path.isdir(path)
12
+
13
+
14
+ def file_exists(path: str) -> bool:
15
+ """Check if path exists."""
16
+ return os.path.exists(path)
17
+
18
+
19
+ def read_text(path: str) -> str:
20
+ """Read text from file with UTF-8 encoding."""
21
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
22
+ return f.read()
23
+
24
+
25
+ def write_text(path: str, content: str) -> None:
26
+ """Write text to file, creating parent directories if needed."""
27
+ parent = Path(path).parent
28
+ parent.mkdir(parents=True, exist_ok=True)
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ f.write(content)
@@ -26,7 +26,7 @@ class ApplyPatchHandler:
26
26
  return model.ToolResultItem(
27
27
  status="success",
28
28
  output=output,
29
- ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text),
29
+ ui_extra=model.DiffTextUIExtra(diff_text=diff_text),
30
30
  )
31
31
 
32
32
  @staticmethod
@@ -7,38 +7,13 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
12
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
13
  from klaude_code.core.tool.tool_registry import register
13
14
  from klaude_code.protocol import llm_param, model, tools
14
15
 
15
16
 
16
- def _is_directory(path: str) -> bool:
17
- try:
18
- return Path(path).is_dir()
19
- except Exception:
20
- return False
21
-
22
-
23
- def _file_exists(path: str) -> bool:
24
- try:
25
- return Path(path).exists()
26
- except Exception:
27
- return False
28
-
29
-
30
- def _read_text(path: str) -> str:
31
- with open(path, "r", encoding="utf-8", errors="replace") as f:
32
- return f.read()
33
-
34
-
35
- def _write_text(path: str, content: str) -> None:
36
- parent = Path(path).parent
37
- parent.mkdir(parents=True, exist_ok=True)
38
- with open(path, "w", encoding="utf-8") as f:
39
- f.write(content)
40
-
41
-
42
17
  @register(tools.EDIT)
43
18
  class EditTool(ToolABC):
44
19
  class EditArguments(BaseModel):
@@ -119,7 +94,7 @@ class EditTool(ToolABC):
119
94
  file_path = os.path.abspath(args.file_path)
120
95
 
121
96
  # Common file errors
122
- if _is_directory(file_path):
97
+ if is_directory(file_path):
123
98
  return model.ToolResultItem(
124
99
  status="error",
125
100
  output="<tool_use_error>Illegal operation on a directory. edit</tool_use_error>",
@@ -136,7 +111,7 @@ class EditTool(ToolABC):
136
111
 
137
112
  # FileTracker checks (only for editing existing files)
138
113
  file_tracker = get_current_file_tracker()
139
- if not _file_exists(file_path):
114
+ if not file_exists(file_path):
140
115
  # We require reading before editing
141
116
  return model.ToolResultItem(
142
117
  status="error",
@@ -163,7 +138,7 @@ class EditTool(ToolABC):
163
138
 
164
139
  # Edit existing file: validate and apply
165
140
  try:
166
- before = await asyncio.to_thread(_read_text, file_path)
141
+ before = await asyncio.to_thread(read_text, file_path)
167
142
  except FileNotFoundError:
168
143
  return model.ToolResultItem(
169
144
  status="error",
@@ -197,7 +172,7 @@ class EditTool(ToolABC):
197
172
 
198
173
  # Write back
199
174
  try:
200
- await asyncio.to_thread(_write_text, file_path, after)
175
+ await asyncio.to_thread(write_text, file_path, after)
201
176
  except Exception as e: # pragma: no cover
202
177
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
203
178
 
@@ -212,7 +187,7 @@ class EditTool(ToolABC):
212
187
  )
213
188
  )
214
189
  diff_text = "\n".join(diff_lines)
215
- ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
190
+ ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
216
191
 
217
192
  # Update tracker with new mtime
218
193
  if file_tracker is not None:
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel, Field
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
11
  from klaude_code.core.tool.file.edit_tool import EditTool
11
12
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
12
13
  from klaude_code.core.tool.tool_context import get_current_file_tracker
@@ -14,32 +15,6 @@ from klaude_code.core.tool.tool_registry import register
14
15
  from klaude_code.protocol import llm_param, model, tools
15
16
 
16
17
 
17
- def _is_directory(path: str) -> bool:
18
- try:
19
- return Path(path).is_dir()
20
- except Exception:
21
- return False
22
-
23
-
24
- def _file_exists(path: str) -> bool:
25
- try:
26
- return Path(path).exists()
27
- except Exception:
28
- return False
29
-
30
-
31
- def _read_text(path: str) -> str:
32
- with open(path, "r", encoding="utf-8", errors="replace") as f:
33
- return f.read()
34
-
35
-
36
- def _write_text(path: str, content: str) -> None:
37
- parent = Path(path).parent
38
- parent.mkdir(parents=True, exist_ok=True)
39
- with open(path, "w", encoding="utf-8") as f:
40
- f.write(content)
41
-
42
-
43
18
  @register(tools.MULTI_EDIT)
44
19
  class MultiEditTool(ToolABC):
45
20
  class MultiEditEditItem(BaseModel):
@@ -105,7 +80,7 @@ class MultiEditTool(ToolABC):
105
80
  file_path = os.path.abspath(args.file_path)
106
81
 
107
82
  # Directory error first
108
- if _is_directory(file_path):
83
+ if is_directory(file_path):
109
84
  return model.ToolResultItem(
110
85
  status="error",
111
86
  output="<tool_use_error>Illegal operation on a directory. multi_edit</tool_use_error>",
@@ -114,7 +89,7 @@ class MultiEditTool(ToolABC):
114
89
  file_tracker = get_current_file_tracker()
115
90
 
116
91
  # FileTracker check:
117
- if _file_exists(file_path):
92
+ if file_exists(file_path):
118
93
  if file_tracker is not None:
119
94
  tracked = file_tracker.get(file_path)
120
95
  if tracked is None:
@@ -142,8 +117,8 @@ class MultiEditTool(ToolABC):
142
117
  )
143
118
 
144
119
  # Load initial content (empty for new file case)
145
- if _file_exists(file_path):
146
- before = await asyncio.to_thread(_read_text, file_path)
120
+ if file_exists(file_path):
121
+ before = await asyncio.to_thread(read_text, file_path)
147
122
  else:
148
123
  before = ""
149
124
 
@@ -168,7 +143,7 @@ class MultiEditTool(ToolABC):
168
143
 
169
144
  # All edits valid; write to disk
170
145
  try:
171
- await asyncio.to_thread(_write_text, file_path, staged)
146
+ await asyncio.to_thread(write_text, file_path, staged)
172
147
  except Exception as e: # pragma: no cover
173
148
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
174
149
 
@@ -183,7 +158,7 @@ class MultiEditTool(ToolABC):
183
158
  )
184
159
  )
185
160
  diff_text = "\n".join(diff_lines)
186
- ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
161
+ ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
187
162
 
188
163
  # Update tracker
189
164
  if file_tracker is not None:
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from pydantic import BaseModel, Field
10
10
 
11
11
  from klaude_code import const
12
+ from klaude_code.core.tool.file._utils import file_exists, is_directory
12
13
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
13
14
  from klaude_code.core.tool.tool_context import get_current_file_tracker
14
15
  from klaude_code.core.tool.tool_registry import register
@@ -34,20 +35,6 @@ def _format_numbered_line(line_no: int, content: str) -> str:
34
35
  return f"{line_no:>6}→{content}"
35
36
 
36
37
 
37
- def _is_directory(path: str) -> bool:
38
- try:
39
- return Path(path).is_dir()
40
- except Exception:
41
- return False
42
-
43
-
44
- def _file_exists(path: str) -> bool:
45
- try:
46
- return Path(path).exists()
47
- except Exception:
48
- return False
49
-
50
-
51
38
  @dataclass
52
39
  class ReadOptions:
53
40
  file_path: str
@@ -101,7 +88,7 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
101
88
 
102
89
  def _track_file_access(file_path: str) -> None:
103
90
  file_tracker = get_current_file_tracker()
104
- if file_tracker is None or not _file_exists(file_path) or _is_directory(file_path):
91
+ if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
105
92
  return
106
93
  try:
107
94
  file_tracker[file_path] = Path(file_path).stat().st_mtime
@@ -188,12 +175,12 @@ class ReadTool(ToolABC):
188
175
  char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
189
176
 
190
177
  # Common file errors
191
- if _is_directory(file_path):
178
+ if is_directory(file_path):
192
179
  return model.ToolResultItem(
193
180
  status="error",
194
181
  output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
195
182
  )
196
- if not _file_exists(file_path):
183
+ if not file_exists(file_path):
197
184
  return model.ToolResultItem(
198
185
  status="error",
199
186
  output="<tool_use_error>File does not exist.</tool_use_error>",
@@ -222,7 +209,8 @@ class ReadTool(ToolABC):
222
209
  # If file is too large and no pagination provided (only check if limits are enabled)
223
210
  try:
224
211
  size_bytes = Path(file_path).stat().st_size
225
- except Exception:
212
+ except OSError:
213
+ # Best-effort size detection; on stat errors fall back to treating size as unknown.
226
214
  size_bytes = 0
227
215
 
228
216
  is_image_file = _is_supported_image_file(file_path)