klaude-code 1.2.18__py3-none-any.whl → 1.2.20__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 (74) hide show
  1. klaude_code/cli/main.py +42 -22
  2. klaude_code/cli/runtime.py +46 -2
  3. klaude_code/{version.py → cli/self_update.py} +110 -2
  4. klaude_code/command/__init__.py +1 -3
  5. klaude_code/command/clear_cmd.py +5 -4
  6. klaude_code/command/command_abc.py +5 -40
  7. klaude_code/command/debug_cmd.py +2 -2
  8. klaude_code/command/diff_cmd.py +2 -1
  9. klaude_code/command/export_cmd.py +14 -49
  10. klaude_code/command/export_online_cmd.py +10 -4
  11. klaude_code/command/help_cmd.py +2 -1
  12. klaude_code/command/model_cmd.py +7 -5
  13. klaude_code/command/prompt-jj-workspace.md +18 -0
  14. klaude_code/command/prompt_command.py +16 -9
  15. klaude_code/command/refresh_cmd.py +3 -2
  16. klaude_code/command/registry.py +98 -28
  17. klaude_code/command/release_notes_cmd.py +2 -1
  18. klaude_code/command/status_cmd.py +2 -1
  19. klaude_code/command/terminal_setup_cmd.py +2 -1
  20. klaude_code/command/thinking_cmd.py +6 -4
  21. klaude_code/core/executor.py +187 -180
  22. klaude_code/core/manager/sub_agent_manager.py +3 -0
  23. klaude_code/core/prompt.py +4 -1
  24. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  25. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  26. klaude_code/core/reminders.py +70 -26
  27. klaude_code/core/task.py +13 -12
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  30. klaude_code/core/tool/file/edit_tool.py +7 -5
  31. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  32. klaude_code/core/tool/file/read_tool.md +1 -1
  33. klaude_code/core/tool/file/read_tool.py +8 -4
  34. klaude_code/core/tool/file/write_tool.py +8 -6
  35. klaude_code/core/tool/memory/skill_loader.py +12 -10
  36. klaude_code/core/tool/shell/bash_tool.py +89 -17
  37. klaude_code/core/tool/sub_agent_tool.py +5 -1
  38. klaude_code/core/tool/tool_abc.py +18 -0
  39. klaude_code/core/tool/tool_context.py +6 -6
  40. klaude_code/core/tool/tool_registry.py +1 -1
  41. klaude_code/core/tool/tool_runner.py +7 -7
  42. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  43. klaude_code/core/tool/web/web_search_tool.py +5 -1
  44. klaude_code/llm/anthropic/client.py +25 -9
  45. klaude_code/llm/openai_compatible/client.py +5 -2
  46. klaude_code/llm/openrouter/client.py +7 -3
  47. klaude_code/llm/responses/client.py +6 -1
  48. klaude_code/protocol/model.py +8 -1
  49. klaude_code/protocol/op.py +47 -0
  50. klaude_code/protocol/op_handler.py +25 -1
  51. klaude_code/protocol/sub_agent/web.py +1 -1
  52. klaude_code/session/codec.py +71 -0
  53. klaude_code/session/export.py +21 -11
  54. klaude_code/session/session.py +186 -322
  55. klaude_code/session/store.py +215 -0
  56. klaude_code/session/templates/export_session.html +48 -47
  57. klaude_code/ui/modes/repl/completers.py +211 -71
  58. klaude_code/ui/modes/repl/event_handler.py +7 -23
  59. klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
  60. klaude_code/ui/modes/repl/renderer.py +2 -2
  61. klaude_code/ui/renderers/common.py +54 -0
  62. klaude_code/ui/renderers/developer.py +2 -3
  63. klaude_code/ui/renderers/errors.py +1 -1
  64. klaude_code/ui/renderers/metadata.py +10 -1
  65. klaude_code/ui/renderers/tools.py +3 -4
  66. klaude_code/ui/rich/__init__.py +10 -1
  67. klaude_code/ui/rich/cjk_wrap.py +228 -0
  68. klaude_code/ui/rich/status.py +0 -1
  69. klaude_code/ui/utils/common.py +0 -18
  70. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
  71. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
  72. klaude_code/ui/utils/debouncer.py +0 -42
  73. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
  74. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  import shlex
3
3
  from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
4
5
  from pathlib import Path
5
6
 
6
7
  from pydantic import BaseModel
@@ -31,6 +32,45 @@ def get_last_new_user_input(session: Session) -> str | None:
31
32
  return "\n\n".join(result)
32
33
 
33
34
 
35
+ @dataclass
36
+ class AtPatternSource:
37
+ """Represents an @ pattern with its source file (if from a memory file)."""
38
+
39
+ pattern: str
40
+ mentioned_in: str | None = None
41
+
42
+
43
+ def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
44
+ """Get @ patterns from last user input and developer messages, preserving source info."""
45
+ patterns: list[AtPatternSource] = []
46
+
47
+ for item in reversed(session.conversation_history):
48
+ if isinstance(item, model.ToolResultItem):
49
+ break
50
+
51
+ if isinstance(item, model.UserMessageItem):
52
+ content = item.content or ""
53
+ if "@" in content:
54
+ for match in AT_FILE_PATTERN.finditer(content):
55
+ path_str = match.group("quoted") or match.group("plain")
56
+ if path_str:
57
+ patterns.append(AtPatternSource(pattern=path_str, mentioned_in=None))
58
+ break
59
+
60
+ if isinstance(item, model.DeveloperMessageItem):
61
+ content = item.content or ""
62
+ if "@" not in content:
63
+ continue
64
+ # Use first memory_path as the source if available
65
+ source = item.memory_paths[0] if item.memory_paths else None
66
+ for match in AT_FILE_PATTERN.finditer(content):
67
+ path_str = match.group("quoted") or match.group("plain")
68
+ if path_str:
69
+ patterns.append(AtPatternSource(pattern=path_str, mentioned_in=source))
70
+
71
+ return patterns
72
+
73
+
34
74
  async def _load_at_file_recursive(
35
75
  session: Session,
36
76
  pattern: str,
@@ -99,33 +139,22 @@ async def at_file_reader_reminder(
99
139
  session: Session,
100
140
  ) -> model.DeveloperMessageItem | None:
101
141
  """Parse @foo/bar to read, with recursive loading of nested @ references"""
102
- last_user_input = get_last_new_user_input(session)
103
- if not last_user_input or "@" not in last_user_input:
104
- return None
105
-
106
- at_patterns: list[str] = []
107
-
108
- for match in AT_FILE_PATTERN.finditer(last_user_input):
109
- quoted = match.group("quoted")
110
- plain = match.group("plain")
111
- path_str = quoted if quoted is not None else plain
112
- if path_str:
113
- at_patterns.append(path_str)
114
-
115
- if len(at_patterns) == 0:
142
+ at_pattern_sources = get_at_patterns_with_source(session)
143
+ if not at_pattern_sources:
116
144
  return None
117
145
 
118
146
  at_files: dict[str, model.AtPatternParseResult] = {} # path -> content
119
147
  collected_images: list[model.ImageURLPart] = []
120
148
  visited: set[str] = set()
121
149
 
122
- for pattern in at_patterns:
150
+ for source in at_pattern_sources:
123
151
  await _load_at_file_recursive(
124
152
  session,
125
- pattern,
153
+ source.pattern,
126
154
  at_files,
127
155
  collected_images,
128
156
  visited,
157
+ mentioned_in=source.mentioned_in,
129
158
  )
130
159
 
131
160
  if len(at_files) == 0:
@@ -231,9 +260,9 @@ async def file_changed_externally_reminder(
231
260
  changed_files: list[tuple[str, str, list[model.ImageURLPart] | None]] = []
232
261
  collected_images: list[model.ImageURLPart] = []
233
262
  if session.file_tracker and len(session.file_tracker) > 0:
234
- for path, mtime in session.file_tracker.items():
263
+ for path, status in session.file_tracker.items():
235
264
  try:
236
- if Path(path).stat().st_mtime > mtime:
265
+ if Path(path).stat().st_mtime > status.mtime:
237
266
  context_token = set_tool_context_from_session(session)
238
267
  try:
239
268
  tool_result = await ReadTool.call_with_args(
@@ -282,6 +311,7 @@ def get_memory_paths() -> list[tuple[Path, str]]:
282
311
  ),
283
312
  (Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
284
313
  (Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
314
+ (Path.cwd() / ".claude" / "CLAUDE.md", "project instructions, checked into the codebase"),
285
315
  ]
286
316
 
287
317
 
@@ -313,16 +343,32 @@ async def image_reminder(session: Session) -> model.DeveloperMessageItem | None:
313
343
  )
314
344
 
315
345
 
346
+ def _is_memory_loaded(session: Session, path: str) -> bool:
347
+ """Check if a memory file has already been loaded (tracked with is_memory=True)."""
348
+ status = session.file_tracker.get(path)
349
+ return status is not None and status.is_memory
350
+
351
+
352
+ def _mark_memory_loaded(session: Session, path: str) -> None:
353
+ """Mark a file as loaded memory in file_tracker."""
354
+ try:
355
+ mtime = Path(path).stat().st_mtime
356
+ except (OSError, FileNotFoundError):
357
+ mtime = 0.0
358
+ session.file_tracker[path] = model.FileStatus(mtime=mtime, is_memory=True)
359
+
360
+
316
361
  async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None:
317
362
  """CLAUDE.md AGENTS.md"""
318
363
  memory_paths = get_memory_paths()
319
364
  memories: list[Memory] = []
320
365
  for memory_path, instruction in memory_paths:
321
- if memory_path.exists() and memory_path.is_file() and str(memory_path) not in session.loaded_memory:
366
+ path_str = str(memory_path)
367
+ if memory_path.exists() and memory_path.is_file() and not _is_memory_loaded(session, path_str):
322
368
  try:
323
369
  text = memory_path.read_text()
324
- session.loaded_memory.append(str(memory_path))
325
- memories.append(Memory(path=str(memory_path), instruction=instruction, content=text))
370
+ _mark_memory_loaded(session, path_str)
371
+ memories.append(Memory(path=path_str, instruction=instruction, content=text))
326
372
  except (PermissionError, UnicodeDecodeError, OSError):
327
373
  continue
328
374
  if len(memories) > 0:
@@ -358,7 +404,7 @@ async def last_path_memory_reminder(
358
404
  """Load CLAUDE.md/AGENTS.md from directories containing files in file_tracker.
359
405
 
360
406
  Uses session.file_tracker to detect accessed paths (works for both tool calls
361
- and @ file references). Uses session.loaded_memory to avoid duplicate loading.
407
+ and @ file references). Checks is_memory flag to avoid duplicate loading.
362
408
  """
363
409
  if not session.file_tracker:
364
410
  return None
@@ -367,7 +413,6 @@ async def last_path_memory_reminder(
367
413
  memories: list[Memory] = []
368
414
 
369
415
  cwd = Path.cwd().resolve()
370
- loaded_set: set[str] = set(session.loaded_memory)
371
416
  seen_memory_files: set[str] = set()
372
417
 
373
418
  for p_str in paths:
@@ -395,15 +440,14 @@ async def last_path_memory_reminder(
395
440
  for fname in MEMORY_FILE_NAMES:
396
441
  mem_path = current_dir / fname
397
442
  mem_path_str = str(mem_path)
398
- if mem_path_str in seen_memory_files or mem_path_str in loaded_set:
443
+ if mem_path_str in seen_memory_files or _is_memory_loaded(session, mem_path_str):
399
444
  continue
400
445
  if mem_path.exists() and mem_path.is_file():
401
446
  try:
402
447
  text = mem_path.read_text()
403
448
  except (PermissionError, UnicodeDecodeError, OSError):
404
449
  continue
405
- session.loaded_memory.append(mem_path_str)
406
- loaded_set.add(mem_path_str)
450
+ _mark_memory_loaded(session, mem_path_str)
407
451
  seen_memory_files.add(mem_path_str)
408
452
  memories.append(
409
453
  Memory(
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
@@ -29,6 +29,8 @@ class MetadataAccumulator:
29
29
  self._sub_agent_metadata: list[model.TaskMetadata] = []
30
30
  self._throughput_weighted_sum: float = 0.0
31
31
  self._throughput_tracked_tokens: int = 0
32
+ self._first_token_latency_sum: float = 0.0
33
+ self._first_token_latency_count: int = 0
32
34
  self._turn_count: int = 0
33
35
 
34
36
  def add(self, turn_metadata: model.ResponseMetadataItem) -> None:
@@ -51,13 +53,8 @@ class MetadataAccumulator:
51
53
  acc_usage.context_limit = usage.context_limit
52
54
 
53
55
  if usage.first_token_latency_ms is not None:
54
- if acc_usage.first_token_latency_ms is None:
55
- acc_usage.first_token_latency_ms = usage.first_token_latency_ms
56
- else:
57
- acc_usage.first_token_latency_ms = min(
58
- acc_usage.first_token_latency_ms,
59
- usage.first_token_latency_ms,
60
- )
56
+ self._first_token_latency_sum += usage.first_token_latency_ms
57
+ self._first_token_latency_count += 1
61
58
 
62
59
  if usage.throughput_tps is not None:
63
60
  current_output = usage.output_tokens
@@ -83,6 +80,11 @@ class MetadataAccumulator:
83
80
  else:
84
81
  main.usage.throughput_tps = None
85
82
 
83
+ if self._first_token_latency_count > 0:
84
+ main.usage.first_token_latency_ms = self._first_token_latency_sum / self._first_token_latency_count
85
+ else:
86
+ main.usage.first_token_latency_ms = None
87
+
86
88
  main.task_duration_s = task_duration_s
87
89
  main.turn_count = self._turn_count
88
90
  return model.TaskMetadataItem(main=main, sub_agent_task_metadata=self._sub_agent_metadata)
@@ -98,7 +100,7 @@ class SessionContext:
98
100
  session_id: str
99
101
  get_conversation_history: Callable[[], list[model.ConversationItem]]
100
102
  append_history: Callable[[Sequence[model.ConversationItem]], None]
101
- file_tracker: MutableMapping[str, float]
103
+ file_tracker: FileTracker
102
104
  todo_context: TodoContext
103
105
 
104
106
 
@@ -147,8 +149,7 @@ class TaskExecutor:
147
149
  session_id=session_ctx.session_id,
148
150
  sub_agent_state=ctx.sub_agent_state,
149
151
  )
150
-
151
- 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.
152
153
 
153
154
  profile = ctx.profile
154
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}:"]
@@ -1,5 +1,6 @@
1
1
  Reads a file from the local filesystem. You can access any file directly by using this tool.
2
2
  Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
3
+ When you need to read an image, use this tool.
3
4
 
4
5
  Usage:
5
6
  - The file_path parameter must be an absolute path, not a relative path
@@ -11,4 +12,3 @@ Usage:
11
12
  - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
12
13
  - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
13
14
  - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
14
- - This tool does NOT support reading PDF files. Use a Python script with `pdfplumber` (for text/tables) or `pypdf` (for basic operations) to extract content from PDFs.
@@ -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:
@@ -209,8 +212,9 @@ class ReadTool(ToolABC):
209
212
  return model.ToolResultItem(
210
213
  status="error",
211
214
  output=(
212
- "<tool_use_error>PDF files are not supported by this tool. "
213
- "Please use a Python script with `pdfplumber` to extract text/tables:\n\n"
215
+ "<tool_use_error>PDF files are not supported by this tool.\n"
216
+ "If there's an available skill for PDF, use it.\n"
217
+ "Or use a Python script with `pdfplumber` to extract text/tables:\n\n"
214
218
  "```python\n"
215
219
  "# /// script\n"
216
220
  '# dependencies = ["pdfplumber"]\n'
@@ -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
@@ -132,20 +132,22 @@ class SkillLoader:
132
132
  for user_dir in self.USER_SKILLS_DIRS:
133
133
  expanded_dir = user_dir.expanduser()
134
134
  if expanded_dir.exists():
135
- for skill_file in expanded_dir.rglob("SKILL.md"):
136
- skill = self.load_skill(skill_file, location="user")
137
- if skill:
138
- skills.append(skill)
139
- self.loaded_skills[skill.name] = skill
135
+ for pattern in ("SKILL.md", "skill.md"):
136
+ for skill_file in expanded_dir.rglob(pattern):
137
+ skill = self.load_skill(skill_file, location="user")
138
+ if skill:
139
+ skills.append(skill)
140
+ self.loaded_skills[skill.name] = skill
140
141
 
141
142
  # Load project-level skills (override user skills if same name)
142
143
  project_dir = self.PROJECT_SKILLS_DIR.resolve()
143
144
  if project_dir.exists():
144
- for skill_file in project_dir.rglob("SKILL.md"):
145
- skill = self.load_skill(skill_file, location="project")
146
- if skill:
147
- skills.append(skill)
148
- self.loaded_skills[skill.name] = skill
145
+ for pattern in ("SKILL.md", "skill.md"):
146
+ for skill_file in project_dir.rglob(pattern):
147
+ skill = self.load_skill(skill_file, location="project")
148
+ if skill:
149
+ skills.append(skill)
150
+ self.loaded_skills[skill.name] = skill
149
151
 
150
152
  # Log discovery summary
151
153
  if skills:
@@ -1,5 +1,8 @@
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
5
8
 
@@ -87,22 +90,94 @@ class BashTool(ToolABC):
87
90
 
88
91
  # Run the command using bash -lc so shell semantics work (pipes, &&, etc.)
89
92
  # Capture stdout/stderr, respect timeout, and return a ToolMessage.
93
+ #
94
+ # Important: this tool is intentionally non-interactive.
95
+ # - Always detach stdin (DEVNULL) so interactive programs can't steal REPL input.
96
+ # - Always disable pagers/editors to avoid launching TUI subprocesses that can
97
+ # leave the terminal in a bad state.
90
98
  cmd = ["bash", "-lc", args.command]
91
99
  timeout_sec = max(0.0, args.timeout_ms / 1000.0)
92
100
 
101
+ env = os.environ.copy()
102
+ env.update(
103
+ {
104
+ # Avoid blocking on git/jj prompts.
105
+ "GIT_TERMINAL_PROMPT": "0",
106
+ # Avoid pagers.
107
+ "PAGER": "cat",
108
+ "GIT_PAGER": "cat",
109
+ # Avoid opening editors.
110
+ "EDITOR": "true",
111
+ "VISUAL": "true",
112
+ "GIT_EDITOR": "true",
113
+ "JJ_EDITOR": "true",
114
+ # Encourage non-interactive output.
115
+ "TERM": "dumb",
116
+ }
117
+ )
118
+
119
+ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
120
+ # Best-effort termination. Ensure we don't hang on cancellation.
121
+ if proc.returncode is not None:
122
+ return
123
+
124
+ try:
125
+ if os.name == "posix" and proc.pid is not None:
126
+ os.killpg(proc.pid, signal.SIGTERM)
127
+ else:
128
+ proc.terminate()
129
+ except ProcessLookupError:
130
+ return
131
+ except Exception:
132
+ # Fall back to kill below.
133
+ pass
134
+
135
+ with contextlib.suppress(Exception):
136
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
137
+ return
138
+
139
+ # Escalate to hard kill if it didn't exit quickly.
140
+ with contextlib.suppress(Exception):
141
+ if os.name == "posix" and proc.pid is not None:
142
+ os.killpg(proc.pid, signal.SIGKILL)
143
+ else:
144
+ proc.kill()
145
+ with contextlib.suppress(Exception):
146
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
147
+
93
148
  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
- )
149
+ # Create a dedicated process group so we can terminate the whole tree.
150
+ # (macOS/Linux support start_new_session; Windows does not.)
151
+ kwargs: dict[str, object] = {
152
+ "stdin": asyncio.subprocess.DEVNULL,
153
+ "stdout": asyncio.subprocess.PIPE,
154
+ "stderr": asyncio.subprocess.PIPE,
155
+ "env": env,
156
+ }
157
+ if os.name == "posix":
158
+ kwargs["start_new_session"] = True
159
+ elif os.name == "nt": # pragma: no cover
160
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
102
161
 
103
- stdout = _ANSI_ESCAPE_RE.sub("", completed.stdout or "")
104
- stderr = _ANSI_ESCAPE_RE.sub("", completed.stderr or "")
105
- rc = completed.returncode
162
+ proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
163
+ try:
164
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
165
+ except TimeoutError:
166
+ with contextlib.suppress(Exception):
167
+ await _terminate_process(proc)
168
+ return model.ToolResultItem(
169
+ status="error",
170
+ output=f"Timeout after {args.timeout_ms} ms running: {args.command}",
171
+ )
172
+ except asyncio.CancelledError:
173
+ # Ensure subprocess is stopped and propagate cancellation.
174
+ with contextlib.suppress(Exception):
175
+ await asyncio.shield(_terminate_process(proc))
176
+ raise
177
+
178
+ stdout = _ANSI_ESCAPE_RE.sub("", (stdout_b or b"").decode(errors="replace"))
179
+ stderr = _ANSI_ESCAPE_RE.sub("", (stderr_b or b"").decode(errors="replace"))
180
+ rc = proc.returncode
106
181
 
107
182
  if rc == 0:
108
183
  output = stdout if stdout else ""
@@ -125,17 +200,14 @@ class BashTool(ToolABC):
125
200
  status="error",
126
201
  output=combined.strip(),
127
202
  )
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
203
  except FileNotFoundError:
135
204
  return model.ToolResultItem(
136
205
  status="error",
137
206
  output="bash not found on system path",
138
207
  )
208
+ except asyncio.CancelledError:
209
+ # Propagate cooperative cancellation so outer layers can handle interrupts correctly.
210
+ raise
139
211
  except Exception as e: # safeguard against unexpected failures
140
212
  return model.ToolResultItem(
141
213
  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