klaude-code 1.2.19__py3-none-any.whl → 1.2.21__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 (59) hide show
  1. klaude_code/cli/main.py +23 -0
  2. klaude_code/cli/runtime.py +17 -0
  3. klaude_code/command/__init__.py +1 -3
  4. klaude_code/command/clear_cmd.py +5 -4
  5. klaude_code/command/command_abc.py +5 -40
  6. klaude_code/command/debug_cmd.py +2 -2
  7. klaude_code/command/diff_cmd.py +2 -1
  8. klaude_code/command/export_cmd.py +14 -49
  9. klaude_code/command/export_online_cmd.py +2 -1
  10. klaude_code/command/help_cmd.py +2 -1
  11. klaude_code/command/model_cmd.py +7 -5
  12. klaude_code/command/prompt-jj-workspace.md +18 -0
  13. klaude_code/command/prompt_command.py +16 -9
  14. klaude_code/command/refresh_cmd.py +3 -2
  15. klaude_code/command/registry.py +31 -6
  16. klaude_code/command/release_notes_cmd.py +2 -1
  17. klaude_code/command/status_cmd.py +2 -1
  18. klaude_code/command/terminal_setup_cmd.py +2 -1
  19. klaude_code/command/thinking_cmd.py +12 -1
  20. klaude_code/core/executor.py +177 -190
  21. klaude_code/core/manager/sub_agent_manager.py +3 -0
  22. klaude_code/core/prompt.py +4 -1
  23. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  24. klaude_code/core/reminders.py +70 -26
  25. klaude_code/core/task.py +4 -5
  26. klaude_code/core/tool/__init__.py +2 -0
  27. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  28. klaude_code/core/tool/file/edit_tool.py +7 -5
  29. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  30. klaude_code/core/tool/file/read_tool.py +5 -2
  31. klaude_code/core/tool/file/write_tool.py +8 -6
  32. klaude_code/core/tool/shell/bash_tool.py +90 -17
  33. klaude_code/core/tool/sub_agent_tool.py +5 -1
  34. klaude_code/core/tool/tool_abc.py +18 -0
  35. klaude_code/core/tool/tool_context.py +6 -6
  36. klaude_code/core/tool/tool_runner.py +7 -7
  37. klaude_code/core/tool/web/mermaid_tool.md +26 -0
  38. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  39. klaude_code/core/tool/web/web_search_tool.py +5 -1
  40. klaude_code/protocol/model.py +8 -1
  41. klaude_code/protocol/op.py +47 -0
  42. klaude_code/protocol/op_handler.py +25 -1
  43. klaude_code/protocol/sub_agent/web.py +1 -1
  44. klaude_code/session/codec.py +71 -0
  45. klaude_code/session/export.py +21 -11
  46. klaude_code/session/session.py +182 -331
  47. klaude_code/session/store.py +215 -0
  48. klaude_code/session/templates/export_session.html +13 -14
  49. klaude_code/ui/modes/repl/completers.py +1 -2
  50. klaude_code/ui/modes/repl/event_handler.py +7 -23
  51. klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -6
  52. klaude_code/ui/rich/__init__.py +10 -1
  53. klaude_code/ui/rich/cjk_wrap.py +228 -0
  54. klaude_code/ui/rich/status.py +0 -1
  55. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/METADATA +2 -1
  56. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/RECORD +58 -55
  57. klaude_code/ui/utils/debouncer.py +0 -42
  58. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/WHEEL +0 -0
  59. {klaude_code-1.2.19.dist-info → klaude_code-1.2.21.dist-info}/entry_points.txt +0 -0
klaude_code/core/task.py CHANGED
@@ -2,13 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import time
5
- from collections.abc import AsyncGenerator, Callable, MutableMapping, Sequence
5
+ from collections.abc import AsyncGenerator, Callable, Sequence
6
6
  from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from klaude_code import const
10
10
  from klaude_code.core.reminders import Reminder
11
- from klaude_code.core.tool import TodoContext, ToolABC
11
+ from klaude_code.core.tool import FileTracker, TodoContext, ToolABC
12
12
  from klaude_code.core.turn import TurnError, TurnExecutionContext, TurnExecutor
13
13
  from klaude_code.protocol import events, model
14
14
  from klaude_code.trace import DebugType, log_debug
@@ -100,7 +100,7 @@ class SessionContext:
100
100
  session_id: str
101
101
  get_conversation_history: Callable[[], list[model.ConversationItem]]
102
102
  append_history: Callable[[Sequence[model.ConversationItem]], None]
103
- file_tracker: MutableMapping[str, float]
103
+ file_tracker: FileTracker
104
104
  todo_context: TodoContext
105
105
 
106
106
 
@@ -149,8 +149,7 @@ class TaskExecutor:
149
149
  session_id=session_ctx.session_id,
150
150
  sub_agent_state=ctx.sub_agent_state,
151
151
  )
152
-
153
- session_ctx.append_history([model.UserMessageItem(content=user_input.text, images=user_input.images)])
152
+ del user_input # Persisted by the operation handler before launching the task.
154
153
 
155
154
  profile = ctx.profile
156
155
  metadata_accumulator = MetadataAccumulator(model_name=profile.llm_client.model_name)
@@ -15,6 +15,7 @@ from .todo.todo_write_tool import TodoWriteTool
15
15
  from .todo.update_plan_tool import UpdatePlanTool
16
16
  from .tool_abc import ToolABC
17
17
  from .tool_context import (
18
+ FileTracker,
18
19
  TodoContext,
19
20
  ToolContextToken,
20
21
  build_todo_context,
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "BashTool",
37
38
  "DiffError",
38
39
  "EditTool",
40
+ "FileTracker",
39
41
  "MemoryTool",
40
42
  "MermaidTool",
41
43
  "MultiEditTool",
@@ -80,7 +80,9 @@ class ApplyPatchHandler:
80
80
 
81
81
  if file_tracker is not None:
82
82
  with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
83
- file_tracker[resolved] = Path(resolved).stat().st_mtime
83
+ existing = file_tracker.get(resolved)
84
+ is_mem = existing.is_memory if existing else False
85
+ file_tracker[resolved] = model.FileStatus(mtime=Path(resolved).stat().st_mtime, is_memory=is_mem)
84
86
 
85
87
  def remove_fn(path: str) -> None:
86
88
  resolved = resolve_path(path)
@@ -119,8 +119,8 @@ class EditTool(ToolABC):
119
119
  output=("File has not been read yet. Read it first before writing to it."),
120
120
  )
121
121
  if file_tracker is not None:
122
- tracked = file_tracker.get(file_path)
123
- if tracked is None:
122
+ tracked_status = file_tracker.get(file_path)
123
+ if tracked_status is None:
124
124
  return model.ToolResultItem(
125
125
  status="error",
126
126
  output=("File has not been read yet. Read it first before writing to it."),
@@ -128,8 +128,8 @@ class EditTool(ToolABC):
128
128
  try:
129
129
  current_mtime = Path(file_path).stat().st_mtime
130
130
  except Exception:
131
- current_mtime = tracked
132
- if current_mtime != tracked:
131
+ current_mtime = tracked_status.mtime
132
+ if current_mtime != tracked_status.mtime:
133
133
  return model.ToolResultItem(
134
134
  status="error",
135
135
  output=(
@@ -193,7 +193,9 @@ class EditTool(ToolABC):
193
193
  # Update tracker with new mtime
194
194
  if file_tracker is not None:
195
195
  with contextlib.suppress(Exception):
196
- file_tracker[file_path] = Path(file_path).stat().st_mtime
196
+ existing = file_tracker.get(file_path)
197
+ is_mem = existing.is_memory if existing else False
198
+ file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
197
199
 
198
200
  # Build output message
199
201
  if args.replace_all:
@@ -92,8 +92,8 @@ class MultiEditTool(ToolABC):
92
92
  # FileTracker check:
93
93
  if file_exists(file_path):
94
94
  if file_tracker is not None:
95
- tracked = file_tracker.get(file_path)
96
- if tracked is None:
95
+ tracked_status = file_tracker.get(file_path)
96
+ if tracked_status is None:
97
97
  return model.ToolResultItem(
98
98
  status="error",
99
99
  output=("File has not been read yet. Read it first before writing to it."),
@@ -101,8 +101,8 @@ class MultiEditTool(ToolABC):
101
101
  try:
102
102
  current_mtime = Path(file_path).stat().st_mtime
103
103
  except Exception:
104
- current_mtime = tracked
105
- if current_mtime != tracked:
104
+ current_mtime = tracked_status.mtime
105
+ if current_mtime != tracked_status.mtime:
106
106
  return model.ToolResultItem(
107
107
  status="error",
108
108
  output=(
@@ -164,7 +164,9 @@ class MultiEditTool(ToolABC):
164
164
  # Update tracker
165
165
  if file_tracker is not None:
166
166
  with contextlib.suppress(Exception):
167
- file_tracker[file_path] = Path(file_path).stat().st_mtime
167
+ existing = file_tracker.get(file_path)
168
+ is_mem = existing.is_memory if existing else False
169
+ file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
168
170
 
169
171
  # Build output message
170
172
  lines = [f"Applied {len(args.edits)} edits to {file_path}:"]
@@ -106,12 +106,15 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
106
106
  )
107
107
 
108
108
 
109
- def _track_file_access(file_path: str) -> None:
109
+ def _track_file_access(file_path: str, *, is_memory: bool = False) -> None:
110
110
  file_tracker = get_current_file_tracker()
111
111
  if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
112
112
  return
113
113
  with contextlib.suppress(Exception):
114
- file_tracker[file_path] = Path(file_path).stat().st_mtime
114
+ existing = file_tracker.get(file_path)
115
+ # Preserve is_memory flag if already set
116
+ is_mem = is_memory or (existing.is_memory if existing else False)
117
+ file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
115
118
 
116
119
 
117
120
  def _is_supported_image_file(file_path: str) -> bool:
@@ -64,10 +64,10 @@ class WriteTool(ToolABC):
64
64
  exists = file_exists(file_path)
65
65
 
66
66
  if exists:
67
- tracked_mtime: float | None = None
67
+ tracked_status: model.FileStatus | None = None
68
68
  if file_tracker is not None:
69
- tracked_mtime = file_tracker.get(file_path)
70
- if tracked_mtime is None:
69
+ tracked_status = file_tracker.get(file_path)
70
+ if tracked_status is None:
71
71
  return model.ToolResultItem(
72
72
  status="error",
73
73
  output=("File has not been read yet. Read it first before writing to it."),
@@ -75,8 +75,8 @@ class WriteTool(ToolABC):
75
75
  try:
76
76
  current_mtime = Path(file_path).stat().st_mtime
77
77
  except Exception:
78
- current_mtime = tracked_mtime
79
- if current_mtime != tracked_mtime:
78
+ current_mtime = tracked_status.mtime
79
+ if current_mtime != tracked_status.mtime:
80
80
  return model.ToolResultItem(
81
81
  status="error",
82
82
  output=(
@@ -100,7 +100,9 @@ class WriteTool(ToolABC):
100
100
 
101
101
  if file_tracker is not None:
102
102
  with contextlib.suppress(Exception):
103
- file_tracker[file_path] = Path(file_path).stat().st_mtime
103
+ existing = file_tracker.get(file_path)
104
+ is_mem = existing.is_memory if existing else False
105
+ file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
104
106
 
105
107
  # Build diff between previous and new content
106
108
  after = args.content
@@ -1,7 +1,11 @@
1
1
  import asyncio
2
+ import contextlib
3
+ import os
2
4
  import re
5
+ import signal
3
6
  import subprocess
4
7
  from pathlib import Path
8
+ from typing import Any
5
9
 
6
10
  from pydantic import BaseModel
7
11
 
@@ -87,22 +91,94 @@ class BashTool(ToolABC):
87
91
 
88
92
  # Run the command using bash -lc so shell semantics work (pipes, &&, etc.)
89
93
  # Capture stdout/stderr, respect timeout, and return a ToolMessage.
94
+ #
95
+ # Important: this tool is intentionally non-interactive.
96
+ # - Always detach stdin (DEVNULL) so interactive programs can't steal REPL input.
97
+ # - Always disable pagers/editors to avoid launching TUI subprocesses that can
98
+ # leave the terminal in a bad state.
90
99
  cmd = ["bash", "-lc", args.command]
91
100
  timeout_sec = max(0.0, args.timeout_ms / 1000.0)
92
101
 
102
+ env = os.environ.copy()
103
+ env.update(
104
+ {
105
+ # Avoid blocking on git/jj prompts.
106
+ "GIT_TERMINAL_PROMPT": "0",
107
+ # Avoid pagers.
108
+ "PAGER": "cat",
109
+ "GIT_PAGER": "cat",
110
+ # Avoid opening editors.
111
+ "EDITOR": "true",
112
+ "VISUAL": "true",
113
+ "GIT_EDITOR": "true",
114
+ "JJ_EDITOR": "true",
115
+ # Encourage non-interactive output.
116
+ "TERM": "dumb",
117
+ }
118
+ )
119
+
120
+ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
121
+ # Best-effort termination. Ensure we don't hang on cancellation.
122
+ if proc.returncode is not None:
123
+ return
124
+
125
+ try:
126
+ if os.name == "posix":
127
+ os.killpg(proc.pid, signal.SIGTERM)
128
+ else:
129
+ proc.terminate()
130
+ except ProcessLookupError:
131
+ return
132
+ except Exception:
133
+ # Fall back to kill below.
134
+ pass
135
+
136
+ with contextlib.suppress(Exception):
137
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
138
+ return
139
+
140
+ # Escalate to hard kill if it didn't exit quickly.
141
+ with contextlib.suppress(Exception):
142
+ if os.name == "posix":
143
+ os.killpg(proc.pid, signal.SIGKILL)
144
+ else:
145
+ proc.kill()
146
+ with contextlib.suppress(Exception):
147
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
148
+
93
149
  try:
94
- completed = await asyncio.to_thread(
95
- subprocess.run,
96
- cmd,
97
- capture_output=True,
98
- text=True,
99
- timeout=timeout_sec,
100
- check=False,
101
- )
150
+ # Create a dedicated process group so we can terminate the whole tree.
151
+ # (macOS/Linux support start_new_session; Windows does not.)
152
+ kwargs: dict[str, Any] = {
153
+ "stdin": asyncio.subprocess.DEVNULL,
154
+ "stdout": asyncio.subprocess.PIPE,
155
+ "stderr": asyncio.subprocess.PIPE,
156
+ "env": env,
157
+ }
158
+ if os.name == "posix":
159
+ kwargs["start_new_session"] = True
160
+ elif os.name == "nt": # pragma: no cover
161
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
102
162
 
103
- stdout = _ANSI_ESCAPE_RE.sub("", completed.stdout or "")
104
- stderr = _ANSI_ESCAPE_RE.sub("", completed.stderr or "")
105
- rc = completed.returncode
163
+ proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
164
+ try:
165
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
166
+ except TimeoutError:
167
+ with contextlib.suppress(Exception):
168
+ await _terminate_process(proc)
169
+ return model.ToolResultItem(
170
+ status="error",
171
+ output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
172
+ )
173
+ except asyncio.CancelledError:
174
+ # Ensure subprocess is stopped and propagate cancellation.
175
+ with contextlib.suppress(Exception):
176
+ await asyncio.shield(_terminate_process(proc))
177
+ raise
178
+
179
+ stdout = _ANSI_ESCAPE_RE.sub("", (stdout_b or b"").decode(errors="replace"))
180
+ stderr = _ANSI_ESCAPE_RE.sub("", (stderr_b or b"").decode(errors="replace"))
181
+ rc = proc.returncode
106
182
 
107
183
  if rc == 0:
108
184
  output = stdout if stdout else ""
@@ -125,17 +201,14 @@ class BashTool(ToolABC):
125
201
  status="error",
126
202
  output=combined.strip(),
127
203
  )
128
-
129
- except subprocess.TimeoutExpired:
130
- return model.ToolResultItem(
131
- status="error",
132
- output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
133
- )
134
204
  except FileNotFoundError:
135
205
  return model.ToolResultItem(
136
206
  status="error",
137
207
  output="bash not found on system path",
138
208
  )
209
+ except asyncio.CancelledError:
210
+ # Propagate cooperative cancellation so outer layers can handle interrupts correctly.
211
+ raise
139
212
  except Exception as e: # safeguard against unexpected failures
140
213
  return model.ToolResultItem(
141
214
  status="error",
@@ -10,7 +10,7 @@ import asyncio
10
10
  import json
11
11
  from typing import TYPE_CHECKING, ClassVar
12
12
 
13
- from klaude_code.core.tool.tool_abc import ToolABC
13
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata
14
14
  from klaude_code.core.tool.tool_context import current_run_subtask_callback
15
15
  from klaude_code.protocol import llm_param, model
16
16
 
@@ -36,6 +36,10 @@ class SubAgentTool(ToolABC):
36
36
  {"_profile": profile},
37
37
  )
38
38
 
39
+ @classmethod
40
+ def metadata(cls) -> ToolMetadata:
41
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
42
+
39
43
  @classmethod
40
44
  def schema(cls) -> llm_param.ToolSchema:
41
45
  profile = cls._profile
@@ -1,5 +1,7 @@
1
1
  import string
2
2
  from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass
4
+ from enum import Enum
3
5
  from pathlib import Path
4
6
 
5
7
  from klaude_code.protocol import llm_param, model
@@ -14,6 +16,10 @@ def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
14
16
 
15
17
 
16
18
  class ToolABC(ABC):
19
+ @classmethod
20
+ def metadata(cls) -> "ToolMetadata":
21
+ return ToolMetadata()
22
+
17
23
  @classmethod
18
24
  @abstractmethod
19
25
  def schema(cls) -> llm_param.ToolSchema:
@@ -23,3 +29,15 @@ class ToolABC(ABC):
23
29
  @abstractmethod
24
30
  async def call(cls, arguments: str) -> model.ToolResultItem:
25
31
  raise NotImplementedError
32
+
33
+
34
+ class ToolConcurrencyPolicy(str, Enum):
35
+ SEQUENTIAL = "sequential"
36
+ CONCURRENT = "concurrent"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ToolMetadata:
41
+ concurrency_policy: ToolConcurrencyPolicy = ToolConcurrencyPolicy.SEQUENTIAL
42
+ has_side_effects: bool = False
43
+ requires_tool_context: bool = True
@@ -9,6 +9,8 @@ from klaude_code.protocol import model
9
9
  from klaude_code.protocol.sub_agent import SubAgentResult
10
10
  from klaude_code.session.session import Session
11
11
 
12
+ type FileTracker = MutableMapping[str, model.FileStatus]
13
+
12
14
 
13
15
  @dataclass
14
16
  class TodoContext:
@@ -44,15 +46,13 @@ class ToolContextToken:
44
46
  finishes running.
45
47
  """
46
48
 
47
- file_tracker_token: Token[MutableMapping[str, float] | None] | None
49
+ file_tracker_token: Token[FileTracker | None] | None
48
50
  todo_token: Token[TodoContext | None] | None
49
51
 
50
52
 
51
53
  # Holds the current file tracker mapping for tool execution context.
52
54
  # Set by Agent/Reminder right before invoking a tool.
53
- current_file_tracker_var: ContextVar[MutableMapping[str, float] | None] = ContextVar(
54
- "current_file_tracker", default=None
55
- )
55
+ current_file_tracker_var: ContextVar[FileTracker | None] = ContextVar("current_file_tracker", default=None)
56
56
 
57
57
 
58
58
  # Holds the todo access context for tools.
@@ -83,7 +83,7 @@ def reset_tool_context(token: ToolContextToken) -> None:
83
83
 
84
84
 
85
85
  @contextmanager
86
- def tool_context(file_tracker: MutableMapping[str, float], todo_ctx: TodoContext) -> Generator[ToolContextToken]:
86
+ def tool_context(file_tracker: FileTracker, todo_ctx: TodoContext) -> Generator[ToolContextToken]:
87
87
  """Context manager for setting and resetting tool execution context."""
88
88
 
89
89
  file_tracker_token = current_file_tracker_var.set(file_tracker)
@@ -102,7 +102,7 @@ def build_todo_context(session: Session) -> TodoContext:
102
102
  return TodoContext(get_todos=store.get, set_todos=store.set)
103
103
 
104
104
 
105
- def get_current_file_tracker() -> MutableMapping[str, float] | None:
105
+ def get_current_file_tracker() -> FileTracker | None:
106
106
  """Return the current file tracker mapping for this tool context."""
107
107
 
108
108
  return current_file_tracker_var.get()
@@ -4,13 +4,9 @@ from dataclasses import dataclass
4
4
 
5
5
  from klaude_code import const
6
6
  from klaude_code.core.tool.report_back_tool import ReportBackTool
7
- from klaude_code.core.tool.tool_abc import ToolABC
7
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy
8
8
  from klaude_code.core.tool.truncation import truncate_tool_output
9
9
  from klaude_code.protocol import model, tools
10
- from klaude_code.protocol.sub_agent import is_sub_agent_tool
11
-
12
- # Tools that can run concurrently (IO-bound, no local state mutations)
13
- _CONCURRENT_TOOLS: frozenset[str] = frozenset({tools.WEB_SEARCH, tools.WEB_FETCH})
14
10
 
15
11
 
16
12
  async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolABC]]) -> model.ToolResultItem:
@@ -214,14 +210,18 @@ class ToolExecutor:
214
210
 
215
211
  task.add_done_callback(_cleanup)
216
212
 
217
- @staticmethod
218
213
  def _partition_tool_calls(
214
+ self,
219
215
  tool_calls: list[model.ToolCallItem],
220
216
  ) -> tuple[list[model.ToolCallItem], list[model.ToolCallItem]]:
221
217
  sequential_tool_calls: list[model.ToolCallItem] = []
222
218
  concurrent_tool_calls: list[model.ToolCallItem] = []
223
219
  for tool_call in tool_calls:
224
- if is_sub_agent_tool(tool_call.name) or tool_call.name in _CONCURRENT_TOOLS:
220
+ tool_cls = self._registry.get(tool_call.name)
221
+ policy = (
222
+ tool_cls.metadata().concurrency_policy if tool_cls is not None else ToolConcurrencyPolicy.SEQUENTIAL
223
+ )
224
+ if policy == ToolConcurrencyPolicy.CONCURRENT:
225
225
  concurrent_tool_calls.append(tool_call)
226
226
  else:
227
227
  sequential_tool_calls.append(tool_call)
@@ -17,5 +17,31 @@ Diagrams are especially valuable for visualizing:
17
17
  - Sequence and timing of operations
18
18
  - Decision trees and conditional logic
19
19
 
20
+ # Syntax
21
+ - ALWAYS wrap node labels in double quotes, especially when they contain spaces, special characters, or non-ASCII text
22
+ - This applies to all node types: regular nodes, subgraph titles, and edge labels
23
+
24
+ Examples:
25
+ ```mermaid
26
+ graph LR
27
+ A["User Input"] --> B["Process Data"]
28
+ B --> C["Output Result"]
29
+ ```
30
+
31
+ ```mermaid
32
+ flowchart TD
33
+ subgraph auth["Authentication Module"]
34
+ login["Login Service"]
35
+ oauth["OAuth Provider"]
36
+ end
37
+ ```
38
+
39
+ ```mermaid
40
+ sequenceDiagram
41
+ participant client as "Web Client"
42
+ participant server as "API Server"
43
+ client ->> server: "Send Request"
44
+ ```
45
+
20
46
  # Styling
21
47
  - When defining custom classDefs, always define fill color, stroke color, and text color ("fill", "stroke", "color") explicitly
@@ -6,12 +6,12 @@ import urllib.error
6
6
  import urllib.request
7
7
  from http.client import HTTPResponse
8
8
  from pathlib import Path
9
- from urllib.parse import urlparse
9
+ from urllib.parse import quote, urlparse, urlunparse
10
10
 
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from klaude_code import const
14
- from klaude_code.core.tool.tool_abc import ToolABC, load_desc
14
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
15
15
  from klaude_code.core.tool.tool_registry import register
16
16
  from klaude_code.protocol import llm_param, model, tools
17
17
 
@@ -20,15 +20,70 @@ DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
20
20
  WEB_FETCH_SAVE_DIR = Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "web"
21
21
 
22
22
 
23
- def _extract_content_type(response: HTTPResponse) -> str:
24
- """Extract the base content type without charset parameters."""
25
- content_type = response.getheader("Content-Type", "")
26
- return content_type.split(";")[0].strip().lower()
23
+ def _encode_url(url: str) -> str:
24
+ """Encode non-ASCII characters in URL to make it safe for HTTP requests."""
25
+ parsed = urlparse(url)
26
+ encoded_path = quote(parsed.path, safe="/-_.~")
27
+ encoded_query = quote(parsed.query, safe="=&-_.~")
28
+ # Handle IDN (Internationalized Domain Names) by encoding to punycode
29
+ try:
30
+ netloc = parsed.netloc.encode("idna").decode("ascii")
31
+ except UnicodeError:
32
+ netloc = parsed.netloc
33
+ return urlunparse((parsed.scheme, netloc, encoded_path, parsed.params, encoded_query, parsed.fragment))
34
+
35
+
36
+ def _extract_content_type_and_charset(response: HTTPResponse) -> tuple[str, str | None]:
37
+ """Extract the base content type and charset from Content-Type header."""
38
+ content_type_header = response.getheader("Content-Type", "")
39
+ parts = content_type_header.split(";")
40
+ content_type = parts[0].strip().lower()
41
+
42
+ charset = None
43
+ for part in parts[1:]:
44
+ part = part.strip()
45
+ if part.lower().startswith("charset="):
46
+ charset = part[8:].strip().strip("\"'")
47
+ break
48
+
49
+ return content_type, charset
50
+
51
+
52
+ def _detect_encoding(data: bytes, declared_charset: str | None) -> str:
53
+ """Detect the encoding of the data."""
54
+ # 1. Use declared charset from HTTP header if available
55
+ if declared_charset:
56
+ return declared_charset
57
+
58
+ # 2. Try to detect from HTML meta tags (check first 2KB)
59
+ head = data[:2048].lower()
60
+ # <meta charset="xxx">
61
+ if match := re.search(rb'<meta[^>]+charset=["\']?([^"\'\s>]+)', head):
62
+ return match.group(1).decode("ascii", errors="ignore")
63
+ # <meta http-equiv="Content-Type" content="text/html; charset=xxx">
64
+ if match := re.search(rb'content=["\'][^"\']*charset=([^"\'\s;]+)', head):
65
+ return match.group(1).decode("ascii", errors="ignore")
66
+
67
+ # 3. Use chardet for automatic detection
68
+ import chardet
27
69
 
70
+ result = chardet.detect(data)
71
+ if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
72
+ return result["encoding"]
28
73
 
29
- def _validate_utf8(data: bytes) -> str:
30
- """Validate and decode bytes as UTF-8."""
31
- return data.decode("utf-8")
74
+ # 4. Default to UTF-8
75
+ return "utf-8"
76
+
77
+
78
+ def _decode_content(data: bytes, declared_charset: str | None) -> str:
79
+ """Decode bytes to string with automatic encoding detection."""
80
+ encoding = _detect_encoding(data, declared_charset)
81
+
82
+ try:
83
+ return data.decode(encoding)
84
+ except (UnicodeDecodeError, LookupError):
85
+ # Fallback: try UTF-8 with replacement for invalid chars
86
+ return data.decode("utf-8", errors="replace")
32
87
 
33
88
 
34
89
  def _convert_html_to_markdown(html: str) -> str:
@@ -98,17 +153,22 @@ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
98
153
  "Accept": "text/markdown, */*",
99
154
  "User-Agent": DEFAULT_USER_AGENT,
100
155
  }
101
- request = urllib.request.Request(url, headers=headers)
156
+ encoded_url = _encode_url(url)
157
+ request = urllib.request.Request(encoded_url, headers=headers)
102
158
 
103
159
  with urllib.request.urlopen(request, timeout=timeout) as response:
104
- content_type = _extract_content_type(response)
160
+ content_type, charset = _extract_content_type_and_charset(response)
105
161
  data = response.read()
106
- text = _validate_utf8(data)
162
+ text = _decode_content(data, charset)
107
163
  return content_type, text
108
164
 
109
165
 
110
166
  @register(tools.WEB_FETCH)
111
167
  class WebFetchTool(ToolABC):
168
+ @classmethod
169
+ def metadata(cls) -> ToolMetadata:
170
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
171
+
112
172
  @classmethod
113
173
  def schema(cls) -> llm_param.ToolSchema:
114
174
  return llm_param.ToolSchema(
@@ -149,7 +209,7 @@ class WebFetchTool(ToolABC):
149
209
  if not url.startswith(("http://", "https://")):
150
210
  return model.ToolResultItem(
151
211
  status="error",
152
- output="Invalid URL: must start with http:// or https://",
212
+ output=f"Invalid URL: must start with http:// or https:// (url={url})",
153
213
  )
154
214
 
155
215
  try:
@@ -170,25 +230,20 @@ class WebFetchTool(ToolABC):
170
230
  except urllib.error.HTTPError as e:
171
231
  return model.ToolResultItem(
172
232
  status="error",
173
- output=f"HTTP error {e.code}: {e.reason}",
233
+ output=f"HTTP error {e.code}: {e.reason} (url={url})",
174
234
  )
175
235
  except urllib.error.URLError as e:
176
236
  return model.ToolResultItem(
177
237
  status="error",
178
- output=f"URL error: {e.reason}",
179
- )
180
- except UnicodeDecodeError as e:
181
- return model.ToolResultItem(
182
- status="error",
183
- output=f"Content is not valid UTF-8: {e}",
238
+ output=f"URL error: {e.reason} (url={url})",
184
239
  )
185
240
  except TimeoutError:
186
241
  return model.ToolResultItem(
187
242
  status="error",
188
- output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
243
+ output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds (url={url})",
189
244
  )
190
245
  except Exception as e:
191
246
  return model.ToolResultItem(
192
247
  status="error",
193
- output=f"Failed to fetch URL: {e}",
248
+ output=f"Failed to fetch URL: {e} (url={url})",
194
249
  )
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from pydantic import BaseModel
6
6
 
7
- from klaude_code.core.tool.tool_abc import ToolABC, load_desc
7
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
8
8
  from klaude_code.core.tool.tool_registry import register
9
9
  from klaude_code.protocol import llm_param, model, tools
10
10
 
@@ -62,6 +62,10 @@ def _format_results(results: list[SearchResult]) -> str:
62
62
 
63
63
  @register(tools.WEB_SEARCH)
64
64
  class WebSearchTool(ToolABC):
65
+ @classmethod
66
+ def metadata(cls) -> ToolMetadata:
67
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=False)
68
+
65
69
  @classmethod
66
70
  def schema(cls) -> llm_param.ToolSchema:
67
71
  return llm_param.ToolSchema(