klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
5
+ import hashlib
4
6
  import os
5
7
  from base64 import b64encode
6
8
  from dataclasses import dataclass
@@ -9,17 +11,12 @@ from pathlib import Path
9
11
  from pydantic import BaseModel, Field
10
12
 
11
13
  from klaude_code import const
14
+ from klaude_code.core.tool.file._utils import file_exists, is_directory
12
15
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
13
16
  from klaude_code.core.tool.tool_context import get_current_file_tracker
14
17
  from klaude_code.core.tool.tool_registry import register
15
18
  from klaude_code.protocol import llm_param, model, tools
16
19
 
17
- SYSTEM_REMINDER_MALICIOUS = (
18
- "<system-reminder>\n"
19
- "Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.\n"
20
- "</system-reminder>"
21
- )
22
-
23
20
  _IMAGE_MIME_TYPES: dict[str, str] = {
24
21
  ".png": "image/png",
25
22
  ".jpg": "image/jpeg",
@@ -28,24 +25,22 @@ _IMAGE_MIME_TYPES: dict[str, str] = {
28
25
  ".webp": "image/webp",
29
26
  }
30
27
 
31
-
32
- def _format_numbered_line(line_no: int, content: str) -> str:
33
- # 6-width right-aligned line number followed by a right arrow
34
- return f"{line_no:>6}→{content}"
28
+ _BINARY_CHECK_SIZE = 8192
35
29
 
36
30
 
37
- def _is_directory(path: str) -> bool:
31
+ def _is_binary_file(file_path: str) -> bool:
32
+ """Check if a file is binary by looking for null bytes in the first chunk."""
38
33
  try:
39
- return Path(path).is_dir()
40
- except Exception:
34
+ with open(file_path, "rb") as f:
35
+ chunk = f.read(_BINARY_CHECK_SIZE)
36
+ return b"\x00" in chunk
37
+ except OSError:
41
38
  return False
42
39
 
43
40
 
44
- def _file_exists(path: str) -> bool:
45
- try:
46
- return Path(path).exists()
47
- except Exception:
48
- return False
41
+ def _format_numbered_line(line_no: int, content: str) -> str:
42
+ # 6-width right-aligned line number followed by a right arrow
43
+ return f"{line_no:>6}→{content}"
49
44
 
50
45
 
51
46
  @dataclass
@@ -55,6 +50,7 @@ class ReadOptions:
55
50
  limit: int | None
56
51
  char_limit_per_line: int | None = const.READ_CHAR_LIMIT_PER_LINE
57
52
  global_line_cap: int | None = const.READ_GLOBAL_LINE_CAP
53
+ max_total_chars: int | None = const.READ_MAX_CHARS
58
54
 
59
55
 
60
56
  @dataclass
@@ -63,20 +59,32 @@ class ReadSegmentResult:
63
59
  selected_lines: list[tuple[int, str]]
64
60
  selected_chars_count: int
65
61
  remaining_selected_beyond_cap: int
62
+ remaining_due_to_char_limit: int
63
+ content_sha256: str
66
64
 
67
65
 
68
66
  def _read_segment(options: ReadOptions) -> ReadSegmentResult:
69
67
  total_lines = 0
70
68
  selected_lines_count = 0
71
69
  remaining_selected_beyond_cap = 0
70
+ remaining_due_to_char_limit = 0
72
71
  selected_lines: list[tuple[int, str]] = []
73
72
  selected_chars = 0
74
- with open(options.file_path, "r", encoding="utf-8", errors="replace") as f:
73
+ char_limit_reached = False
74
+ hasher = hashlib.sha256()
75
+
76
+ with open(options.file_path, encoding="utf-8", errors="replace") as f:
75
77
  for line_no, raw_line in enumerate(f, start=1):
76
78
  total_lines = line_no
79
+ hasher.update(raw_line.encode("utf-8"))
77
80
  within = line_no >= options.offset and (options.limit is None or selected_lines_count < options.limit)
78
81
  if not within:
79
82
  continue
83
+
84
+ if char_limit_reached:
85
+ remaining_due_to_char_limit += 1
86
+ continue
87
+
80
88
  selected_lines_count += 1
81
89
  content = raw_line.rstrip("\n")
82
90
  original_len = len(content)
@@ -86,27 +94,41 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
86
94
  content[: options.char_limit_per_line]
87
95
  + f" ... (more {truncated_chars} characters in this line are truncated)"
88
96
  )
89
- selected_chars += len(content) + 1
97
+ line_chars = len(content) + 1
98
+ selected_chars += line_chars
99
+
100
+ if options.max_total_chars is not None and selected_chars > options.max_total_chars:
101
+ char_limit_reached = True
102
+ selected_lines.append((line_no, content))
103
+ continue
104
+
90
105
  if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
91
106
  selected_lines.append((line_no, content))
92
107
  else:
93
108
  remaining_selected_beyond_cap += 1
109
+
94
110
  return ReadSegmentResult(
95
111
  total_lines=total_lines,
96
112
  selected_lines=selected_lines,
97
113
  selected_chars_count=selected_chars,
98
114
  remaining_selected_beyond_cap=remaining_selected_beyond_cap,
115
+ remaining_due_to_char_limit=remaining_due_to_char_limit,
116
+ content_sha256=hasher.hexdigest(),
99
117
  )
100
118
 
101
119
 
102
- def _track_file_access(file_path: str) -> None:
120
+ def _track_file_access(file_path: str, *, content_sha256: str | None = None, is_memory: bool = False) -> None:
103
121
  file_tracker = get_current_file_tracker()
104
- if file_tracker is None or not _file_exists(file_path) or _is_directory(file_path):
122
+ if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
105
123
  return
106
- try:
107
- file_tracker[file_path] = Path(file_path).stat().st_mtime
108
- except Exception:
109
- pass
124
+ with contextlib.suppress(Exception):
125
+ existing = file_tracker.get(file_path)
126
+ is_mem = is_memory or (existing.is_memory if existing else False)
127
+ file_tracker[file_path] = model.FileStatus(
128
+ mtime=Path(file_path).stat().st_mtime,
129
+ content_sha256=content_sha256,
130
+ is_memory=is_mem,
131
+ )
110
132
 
111
133
 
112
134
  def _is_supported_image_file(file_path: str) -> bool:
@@ -121,12 +143,6 @@ def _image_mime_type(file_path: str) -> str:
121
143
  return mime_type
122
144
 
123
145
 
124
- def _encode_image_to_data_url(file_path: str, mime_type: str) -> str:
125
- with open(file_path, "rb") as image_file:
126
- encoded = b64encode(image_file.read()).decode("ascii")
127
- return f"data:{mime_type};base64,{encoded}"
128
-
129
-
130
146
  @register(tools.READ)
131
147
  class ReadTool(ToolABC):
132
148
  class ReadArguments(BaseModel):
@@ -170,30 +186,24 @@ class ReadTool(ToolABC):
170
186
  return await cls.call_with_args(args)
171
187
 
172
188
  @classmethod
173
- def _effective_limits(cls) -> tuple[int | None, int | None, int | None, int | None]:
174
- """Return effective limits based on current policy: char_per_line, global_line_cap, max_chars, max_kb"""
189
+ def _effective_limits(cls) -> tuple[int | None, int | None, int | None]:
175
190
  return (
176
191
  const.READ_CHAR_LIMIT_PER_LINE,
177
192
  const.READ_GLOBAL_LINE_CAP,
178
193
  const.READ_MAX_CHARS,
179
- const.READ_MAX_KB,
180
194
  )
181
195
 
182
196
  @classmethod
183
197
  async def call_with_args(cls, args: ReadTool.ReadArguments) -> model.ToolResultItem:
184
- # Accept relative path by resolving to absolute (schema encourages absolute)
185
198
  file_path = os.path.abspath(args.file_path)
199
+ char_per_line, line_cap, max_chars = cls._effective_limits()
186
200
 
187
- # Get effective limits based on policy
188
- char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
189
-
190
- # Common file errors
191
- if _is_directory(file_path):
201
+ if is_directory(file_path):
192
202
  return model.ToolResultItem(
193
203
  status="error",
194
204
  output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
195
205
  )
196
- if not _file_exists(file_path):
206
+ if not file_exists(file_path):
197
207
  return model.ToolResultItem(
198
208
  status="error",
199
209
  output="<tool_use_error>File does not exist.</tool_use_error>",
@@ -204,8 +214,9 @@ class ReadTool(ToolABC):
204
214
  return model.ToolResultItem(
205
215
  status="error",
206
216
  output=(
207
- "<tool_use_error>PDF files are not supported by this tool. "
208
- "Please use a Python script with `pdfplumber` to extract text/tables:\n\n"
217
+ "<tool_use_error>PDF files are not supported by this tool.\n"
218
+ "If there's an available skill for PDF, use it.\n"
219
+ "Or use a Python script with `pdfplumber` to extract text/tables:\n\n"
209
220
  "```python\n"
210
221
  "# /// script\n"
211
222
  '# dependencies = ["pdfplumber"]\n'
@@ -219,13 +230,22 @@ class ReadTool(ToolABC):
219
230
  ),
220
231
  )
221
232
 
222
- # If file is too large and no pagination provided (only check if limits are enabled)
233
+ is_image_file = _is_supported_image_file(file_path)
234
+ # Check for binary files (skip for images which are handled separately)
235
+ if not is_image_file and _is_binary_file(file_path):
236
+ return model.ToolResultItem(
237
+ status="error",
238
+ output=(
239
+ "<tool_use_error>This appears to be a binary file and cannot be read as text. "
240
+ "Use appropriate tools or libraries to handle binary files.</tool_use_error>"
241
+ ),
242
+ )
243
+
223
244
  try:
224
245
  size_bytes = Path(file_path).stat().st_size
225
- except Exception:
246
+ except OSError:
226
247
  size_bytes = 0
227
248
 
228
- is_image_file = _is_supported_image_file(file_path)
229
249
  if is_image_file:
230
250
  if size_bytes > const.READ_MAX_IMAGE_BYTES:
231
251
  size_mb = size_bytes / (1024 * 1024)
@@ -237,42 +257,26 @@ class ReadTool(ToolABC):
237
257
  )
238
258
  try:
239
259
  mime_type = _image_mime_type(file_path)
240
- data_url = _encode_image_to_data_url(file_path, mime_type)
260
+ with open(file_path, "rb") as image_file:
261
+ image_bytes = image_file.read()
262
+ data_url = f"data:{mime_type};base64,{b64encode(image_bytes).decode('ascii')}"
241
263
  except Exception as exc:
242
264
  return model.ToolResultItem(
243
265
  status="error",
244
266
  output=f"<tool_use_error>Failed to read image file: {exc}</tool_use_error>",
245
267
  )
246
268
 
247
- _track_file_access(file_path)
269
+ _track_file_access(file_path, content_sha256=hashlib.sha256(image_bytes).hexdigest())
248
270
  size_kb = size_bytes / 1024.0 if size_bytes else 0.0
249
271
  output_text = f"[image] {Path(file_path).name} ({size_kb:.1f}KB)"
250
272
  image_part = model.ImageURLPart(image_url=model.ImageURLPart.ImageURL(url=data_url, id=None))
251
273
  return model.ToolResultItem(status="success", output=output_text, images=[image_part])
252
274
 
253
- if (
254
- not is_image_file
255
- and max_kb is not None
256
- and args.offset is None
257
- and args.limit is None
258
- and size_bytes > max_kb * 1024
259
- ):
260
- size_kb = size_bytes / 1024.0
261
- return model.ToolResultItem(
262
- status="error",
263
- output=(
264
- f"File content ({size_kb:.1f}KB) exceeds maximum allowed size ({max_kb}KB). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
265
- ),
266
- )
267
-
268
275
  offset = 1 if args.offset is None or args.offset < 1 else int(args.offset)
269
276
  limit = None if args.limit is None else int(args.limit)
270
277
  if limit is not None and limit < 0:
271
278
  limit = 0
272
279
 
273
- # Stream file line-by-line and build response
274
- read_result: ReadSegmentResult | None = None
275
-
276
280
  try:
277
281
  read_result = await asyncio.to_thread(
278
282
  _read_segment,
@@ -282,6 +286,7 @@ class ReadTool(ToolABC):
282
286
  limit=limit,
283
287
  char_limit_per_line=char_per_line,
284
288
  global_line_cap=line_cap,
289
+ max_total_chars=max_chars,
285
290
  ),
286
291
  )
287
292
 
@@ -296,31 +301,26 @@ class ReadTool(ToolABC):
296
301
  output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
297
302
  )
298
303
 
299
- # If offset beyond total lines, emit system reminder warning
300
304
  if offset > max(read_result.total_lines, 0):
301
305
  warn = f"<system-reminder>Warning: the file exists but is shorter than the provided offset ({offset}). The file has {read_result.total_lines} lines.</system-reminder>"
302
- # Update FileTracker (we still consider it as a read attempt)
303
- _track_file_access(file_path)
306
+ _track_file_access(file_path, content_sha256=read_result.content_sha256)
304
307
  return model.ToolResultItem(status="success", output=warn)
305
308
 
306
- # After limit/offset, if total selected chars exceed limit, error (only check if limits are enabled)
307
- if max_chars is not None and read_result.selected_chars_count > max_chars:
308
- return model.ToolResultItem(
309
- status="error",
310
- output=(
311
- f"File content ({read_result.selected_chars_count} chars) exceeds maximum allowed tokens ({max_chars}). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
312
- ),
309
+ lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
310
+
311
+ # Show truncation info with reason
312
+ if read_result.remaining_due_to_char_limit > 0:
313
+ lines_out.append(
314
+ f"... ({read_result.remaining_due_to_char_limit} more lines truncated due to {max_chars} char limit, "
315
+ f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
316
+ )
317
+ elif read_result.remaining_selected_beyond_cap > 0:
318
+ lines_out.append(
319
+ f"... ({read_result.remaining_selected_beyond_cap} more lines truncated due to {line_cap} line limit, "
320
+ f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
313
321
  )
314
322
 
315
- # Build display with numbering and reminders
316
- lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
317
- if read_result.remaining_selected_beyond_cap > 0:
318
- lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
319
323
  read_result_str = "\n".join(lines_out)
320
- # if read_result_str:
321
- # read_result_str += "\n\n" + SYSTEM_REMINDER_MALICIOUS
322
-
323
- # Update FileTracker with last modified time
324
- _track_file_access(file_path)
324
+ _track_file_access(file_path, content_sha256=read_result.content_sha256)
325
325
 
326
326
  return model.ToolResultItem(status="success", output=read_result_str)
@@ -1,44 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import difflib
4
+ import contextlib
5
5
  import os
6
6
  from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
11
+ from klaude_code.core.tool.file.diff_builder import build_structured_diff
10
12
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
13
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
14
  from klaude_code.core.tool.tool_registry import register
13
15
  from klaude_code.protocol import llm_param, model, tools
14
16
 
15
17
 
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 _write_text(path: str, content: str) -> None:
31
- parent = Path(path).parent
32
- parent.mkdir(parents=True, exist_ok=True)
33
- with open(path, "w", encoding="utf-8") as f:
34
- f.write(content)
35
-
36
-
37
- def _read_text(path: str) -> str:
38
- with open(path, "r", encoding="utf-8", errors="replace") as f:
39
- return f.read()
40
-
41
-
42
18
  class WriteArguments(BaseModel):
43
19
  file_path: str
44
20
  content: str
@@ -73,74 +49,88 @@ class WriteTool(ToolABC):
73
49
  async def call(cls, arguments: str) -> model.ToolResultItem:
74
50
  try:
75
51
  args = WriteArguments.model_validate_json(arguments)
76
- except Exception as e: # pragma: no cover - defensive
52
+ except ValueError as e: # pragma: no cover - defensive
77
53
  return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
78
54
 
79
55
  file_path = os.path.abspath(args.file_path)
80
56
 
81
- if _is_directory(file_path):
57
+ if is_directory(file_path):
82
58
  return model.ToolResultItem(
83
59
  status="error",
84
60
  output="<tool_use_error>Illegal operation on a directory. write</tool_use_error>",
85
61
  )
86
62
 
87
63
  file_tracker = get_current_file_tracker()
88
- exists = _file_exists(file_path)
64
+ exists = file_exists(file_path)
65
+ tracked_status: model.FileStatus | None = None
89
66
 
90
67
  if exists:
91
- tracked_mtime: float | None = None
92
- if file_tracker is not None:
93
- tracked_mtime = file_tracker.get(file_path)
94
- if tracked_mtime is None:
68
+ tracked_status = file_tracker.get(file_path) if file_tracker is not None else None
69
+ if tracked_status is None:
95
70
  return model.ToolResultItem(
96
71
  status="error",
97
72
  output=("File has not been read yet. Read it first before writing to it."),
98
73
  )
99
- try:
100
- current_mtime = Path(file_path).stat().st_mtime
101
- except Exception:
102
- current_mtime = tracked_mtime
103
- if current_mtime != tracked_mtime:
104
- return model.ToolResultItem(
105
- status="error",
106
- output=(
107
- "File has been modified externally. Either by user or a linter. "
108
- "Read it first before writing to it."
109
- ),
110
- )
111
74
 
112
- # Capture previous content (if any) for diff generation
75
+ # Capture previous content (if any) for diff generation and external-change detection.
113
76
  before = ""
77
+ before_read_ok = False
114
78
  if exists:
115
79
  try:
116
- before = await asyncio.to_thread(_read_text, file_path)
117
- except Exception:
80
+ before = await asyncio.to_thread(read_text, file_path)
81
+ before_read_ok = True
82
+ except OSError:
118
83
  before = ""
84
+ before_read_ok = False
85
+
86
+ # Re-check external modifications using content hash when available.
87
+ if before_read_ok and tracked_status is not None and tracked_status.content_sha256 is not None:
88
+ current_sha256 = hash_text_sha256(before)
89
+ if current_sha256 != tracked_status.content_sha256:
90
+ return model.ToolResultItem(
91
+ status="error",
92
+ output=(
93
+ "File has been modified externally. Either by user or a linter. "
94
+ "Read it first before writing to it."
95
+ ),
96
+ )
97
+ elif tracked_status is not None:
98
+ # Backward-compat: old sessions only stored mtime, or we couldn't hash.
99
+ try:
100
+ current_mtime = Path(file_path).stat().st_mtime
101
+ except OSError:
102
+ current_mtime = tracked_status.mtime
103
+ if current_mtime != tracked_status.mtime:
104
+ return model.ToolResultItem(
105
+ status="error",
106
+ output=(
107
+ "File has been modified externally. Either by user or a linter. "
108
+ "Read it first before writing to it."
109
+ ),
110
+ )
119
111
 
120
112
  try:
121
- await asyncio.to_thread(_write_text, file_path, args.content)
122
- except Exception as e: # pragma: no cover
113
+ await asyncio.to_thread(write_text, file_path, args.content)
114
+ except (OSError, UnicodeError) as e: # pragma: no cover
123
115
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
124
116
 
125
117
  if file_tracker is not None:
126
- try:
127
- file_tracker[file_path] = Path(file_path).stat().st_mtime
128
- except Exception:
129
- pass
130
-
131
- # Build diff between previous and new content
132
- after = args.content
133
- diff_lines = list(
134
- difflib.unified_diff(
135
- before.splitlines(),
136
- after.splitlines(),
137
- fromfile=file_path,
138
- tofile=file_path,
139
- n=3,
140
- )
141
- )
142
- diff_text = "\n".join(diff_lines)
143
- ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
118
+ with contextlib.suppress(Exception):
119
+ existing = file_tracker.get(file_path)
120
+ is_mem = existing.is_memory if existing else False
121
+ file_tracker[file_path] = model.FileStatus(
122
+ mtime=Path(file_path).stat().st_mtime,
123
+ content_sha256=hash_text_sha256(args.content),
124
+ is_memory=is_mem,
125
+ )
126
+
127
+ # For markdown files, use MarkdownDocUIExtra to render content as markdown
128
+ # Otherwise, build diff between previous and new content
129
+ ui_extra: model.ToolResultUIExtra | None
130
+ if file_path.endswith(".md"):
131
+ ui_extra = model.MarkdownDocUIExtra(file_path=file_path, content=args.content)
132
+ else:
133
+ ui_extra = build_structured_diff(before, args.content, file_path=file_path)
144
134
 
145
135
  message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
146
136
  return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)
@@ -0,0 +1,84 @@
1
+ """ReportBackTool for sub-agents to return structured output."""
2
+
3
+ from typing import Any, ClassVar, cast
4
+
5
+ from klaude_code.protocol import llm_param, model, tools
6
+
7
+
8
+ def _normalize_schema_types(schema: dict[str, Any]) -> dict[str, Any]:
9
+ """Recursively normalize JSON schema type values to lowercase.
10
+
11
+ Some LLMs (e.g., Gemini 3) generate type values in uppercase like "OBJECT", "STRING".
12
+ Standard JSON Schema requires lowercase type values.
13
+ """
14
+ result: dict[str, Any] = {}
15
+ for key, value in schema.items():
16
+ if key == "type" and isinstance(value, str):
17
+ result[key] = value.lower()
18
+ elif isinstance(value, dict):
19
+ result[key] = _normalize_schema_types(cast(dict[str, Any], value))
20
+ elif isinstance(value, list):
21
+ normalized_list: list[Any] = []
22
+ for item in cast(list[Any], value):
23
+ if isinstance(item, dict):
24
+ normalized_list.append(_normalize_schema_types(cast(dict[str, Any], item)))
25
+ else:
26
+ normalized_list.append(item)
27
+ result[key] = normalized_list
28
+ else:
29
+ result[key] = value
30
+ return result
31
+
32
+
33
+ class ReportBackTool:
34
+ """Special tool for sub-agents to return structured output and end the task.
35
+
36
+ This tool is dynamically injected when a parent agent calls a sub-agent with
37
+ an output_schema. The schema for this tool's parameters is defined by the
38
+ parent agent, allowing structured data to be returned.
39
+
40
+ Note: This class does not inherit from ToolABC because it's not registered
41
+ in the global tool registry. Instead, it's handled specially by the
42
+ TurnExecutor and SubAgentManager.
43
+ """
44
+
45
+ _schema: ClassVar[dict[str, Any]] = {}
46
+
47
+ @classmethod
48
+ def for_schema(cls, schema: dict[str, Any]) -> type["ReportBackTool"]:
49
+ """Create a tool class with the specified output schema.
50
+
51
+ Args:
52
+ schema: JSON Schema defining the expected structure of the report_back arguments.
53
+
54
+ Returns:
55
+ A new class with the schema set as a class variable.
56
+ """
57
+ normalized = _normalize_schema_types(schema)
58
+ return type("ReportBackTool", (ReportBackTool,), {"_schema": normalized})
59
+
60
+ @classmethod
61
+ def schema(cls) -> llm_param.ToolSchema:
62
+ """Generate the tool schema for this report_back tool."""
63
+ return llm_param.ToolSchema(
64
+ name=tools.REPORT_BACK,
65
+ type="function",
66
+ description=(
67
+ "Report the final structured result back to the parent agent. "
68
+ "Call this when you have completed the task and want to return structured data. "
69
+ "The task will end after this tool is called."
70
+ ),
71
+ parameters=cls._schema,
72
+ )
73
+
74
+ @classmethod
75
+ async def call(cls, arguments: str) -> model.ToolResultItem:
76
+ """Execute the report_back tool.
77
+
78
+ The actual handling of report_back results is done by TurnExecutor.
79
+ This method just returns a success status to maintain the tool call flow.
80
+ """
81
+ return model.ToolResultItem(
82
+ status="success",
83
+ output="Result reported successfully. Task will end.",
84
+ )