klaude-code 1.2.13__py3-none-any.whl → 1.2.14__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.
@@ -4,6 +4,8 @@ from functools import cache
4
4
  from importlib.resources import files
5
5
  from pathlib import Path
6
6
 
7
+ from klaude_code.protocol import llm_param
8
+
7
9
  COMMAND_DESCRIPTIONS: dict[str, str] = {
8
10
  "rg": "ripgrep - fast text search",
9
11
  "fd": "simple and fast alternative to find",
@@ -36,13 +38,13 @@ def _load_base_prompt(file_key: str) -> str:
36
38
  return files(__package__).joinpath(prompt_path).read_text(encoding="utf-8").strip()
37
39
 
38
40
 
39
- def _get_file_key(model_name: str, sub_agent_type: str | None) -> str:
41
+ def _get_file_key(model_name: str, protocol: llm_param.LLMClientProtocol, sub_agent_type: str | None) -> str:
40
42
  """Determine which prompt file to use based on model and agent type."""
41
43
  if sub_agent_type is not None:
42
44
  return sub_agent_type
43
45
 
44
46
  match model_name:
45
- case "gpt-5.1-codex-max":
47
+ case name if "gpt-5.1-codex-max" in name:
46
48
  return "main_gpt_5_1_codex_max"
47
49
  case name if "gpt-5" in name:
48
50
  return "main_gpt_5_1"
@@ -84,12 +86,15 @@ def _build_env_info(model_name: str) -> str:
84
86
  return "\n".join(env_lines)
85
87
 
86
88
 
87
- def load_system_prompt(model_name: str, sub_agent_type: str | None = None) -> str:
89
+ def load_system_prompt(
90
+ model_name: str, protocol: llm_param.LLMClientProtocol, sub_agent_type: str | None = None
91
+ ) -> str:
88
92
  """Get system prompt content for the given model and sub-agent type."""
89
- file_key = _get_file_key(model_name, sub_agent_type)
93
+ file_key = _get_file_key(model_name, protocol, sub_agent_type)
90
94
  base_prompt = _load_base_prompt(file_key)
91
95
 
92
- if model_name == "gpt-5.1-codex-max":
96
+ if protocol == llm_param.LLMClientProtocol.CODEX:
97
+ # Do not append environment info for Codex protocol
93
98
  return base_prompt
94
99
 
95
100
  return base_prompt + _build_env_info(model_name)
klaude_code/llm/client.py CHANGED
@@ -26,6 +26,10 @@ class LLMClientABC(ABC):
26
26
  def model_name(self) -> str:
27
27
  return self._config.model or ""
28
28
 
29
+ @property
30
+ def protocol(self) -> llm_param.LLMClientProtocol:
31
+ return self._config.protocol
32
+
29
33
 
30
34
  P = ParamSpec("P")
31
35
  R = TypeVar("R")
@@ -15,7 +15,7 @@ from klaude_code.llm.openrouter.reasoning_handler import ReasoningDetail, Reason
15
15
  from klaude_code.llm.registry import register
16
16
  from klaude_code.llm.usage import MetadataTracker, convert_usage
17
17
  from klaude_code.protocol import llm_param, model
18
- from klaude_code.trace import DebugType, log, log_debug
18
+ from klaude_code.trace import DebugType, is_debug_enabled, log, log_debug
19
19
 
20
20
 
21
21
  def build_payload(
@@ -27,10 +27,11 @@ def build_payload(
27
27
 
28
28
  extra_body: dict[str, object] = {
29
29
  "usage": {"include": True}, # To get the cache tokens at the end of the response
30
- "debug": {
31
- "echo_upstream_body": True
32
- }, # https://openrouter.ai/docs/api/reference/errors-and-debugging#debug-option-shape
33
30
  }
31
+ if is_debug_enabled():
32
+ extra_body["debug"] = {
33
+ "echo_upstream_body": True
34
+ } # https://openrouter.ai/docs/api/reference/errors-and-debugging#debug-option-shape
34
35
  extra_headers: dict[str, str] = {}
35
36
 
36
37
  if param.thinking:
@@ -85,7 +85,6 @@ class ToolCallEvent(BaseModel):
85
85
  tool_call_id: str
86
86
  tool_name: str
87
87
  arguments: str
88
- is_replay: bool = False
89
88
 
90
89
 
91
90
  class ToolResultEvent(BaseModel):
@@ -96,7 +95,6 @@ class ToolResultEvent(BaseModel):
96
95
  result: str
97
96
  ui_extra: model.ToolResultUIExtra | None = None
98
97
  status: Literal["success", "error"]
99
- is_replay: bool = False
100
98
  task_metadata: model.TaskMetadata | None = None # Sub-agent task metadata
101
99
 
102
100
 
@@ -136,6 +134,8 @@ class TodoChangeEvent(BaseModel):
136
134
 
137
135
  HistoryItemEvent = (
138
136
  ThinkingEvent
137
+ | TaskStartEvent
138
+ | TaskFinishEvent
139
139
  | TurnStartEvent # This event is used for UI to print new empty line
140
140
  | AssistantMessageEvent
141
141
  | ToolCallEvent
@@ -291,7 +291,6 @@ register_sub_agent(
291
291
  tool_set=(tools.BASH, tools.READ),
292
292
  prompt_builder=_explore_prompt_builder,
293
293
  active_form="Exploring",
294
- target_model_filter=lambda model: ("haiku" not in model) and ("kimi" not in model) and ("grok" not in model),
295
294
  )
296
295
  )
297
296
 
@@ -544,7 +544,13 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
544
544
  def _build_messages_html(
545
545
  history: list[model.ConversationItem],
546
546
  tool_results: dict[str, model.ToolResultItem],
547
+ *,
548
+ seen_session_ids: set[str] | None = None,
549
+ nesting_level: int = 0,
547
550
  ) -> str:
551
+ if seen_session_ids is None:
552
+ seen_session_ids = set()
553
+
548
554
  blocks: list[str] = []
549
555
  assistant_counter = 0
550
556
 
@@ -596,9 +602,61 @@ def _build_messages_html(
596
602
  result = tool_results.get(item.call_id)
597
603
  blocks.append(_format_tool_call(item, result))
598
604
 
605
+ # Recursively render sub-agent session history
606
+ if result is not None:
607
+ sub_agent_html = _render_sub_agent_session(result, seen_session_ids, nesting_level)
608
+ if sub_agent_html:
609
+ blocks.append(sub_agent_html)
610
+
599
611
  return "\n".join(blocks)
600
612
 
601
613
 
614
+ def _render_sub_agent_session(
615
+ tool_result: model.ToolResultItem,
616
+ seen_session_ids: set[str],
617
+ nesting_level: int,
618
+ ) -> str | None:
619
+ """Render sub-agent session history when a tool result references it."""
620
+ from klaude_code.session.session import Session
621
+
622
+ ui_extra = tool_result.ui_extra
623
+ if not isinstance(ui_extra, model.SessionIdUIExtra):
624
+ return None
625
+
626
+ session_id = ui_extra.session_id
627
+ if not session_id or session_id in seen_session_ids:
628
+ return None
629
+
630
+ seen_session_ids.add(session_id)
631
+
632
+ try:
633
+ sub_session = Session.load(session_id)
634
+ except Exception:
635
+ return None
636
+
637
+ sub_history = sub_session.conversation_history
638
+ sub_tool_results = {item.call_id: item for item in sub_history if isinstance(item, model.ToolResultItem)}
639
+
640
+ sub_html = _build_messages_html(
641
+ sub_history,
642
+ sub_tool_results,
643
+ seen_session_ids=seen_session_ids,
644
+ nesting_level=nesting_level + 1,
645
+ )
646
+
647
+ if not sub_html:
648
+ return None
649
+
650
+ # Wrap in a collapsible sub-agent container using same style as other collapsible sections
651
+ indent_style = f' style="margin-left: {nesting_level * 16}px;"' if nesting_level > 0 else ""
652
+ return (
653
+ f'<details class="sub-agent-session"{indent_style}>'
654
+ f"<summary>Sub-agent: {_escape_html(session_id)}</summary>"
655
+ f'<div class="sub-agent-content">{sub_html}</div>'
656
+ f"</details>"
657
+ )
658
+
659
+
602
660
  def build_export_html(
603
661
  session: Session,
604
662
  system_prompt: str,
@@ -267,7 +267,10 @@ class Session(BaseModel):
267
267
  )
268
268
 
269
269
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
270
+ seen_sub_agent_sessions: set[str] = set()
270
271
  prev_item: model.ConversationItem | None = None
272
+ last_assistant_content: str = ""
273
+ yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
271
274
  for it in self.conversation_history:
272
275
  if self.need_turn_start(prev_item, it):
273
276
  yield events.TurnStartEvent(
@@ -276,6 +279,7 @@ class Session(BaseModel):
276
279
  match it:
277
280
  case model.AssistantMessageItem() as am:
278
281
  content = am.content or ""
282
+ last_assistant_content = content
279
283
  yield events.AssistantMessageEvent(
280
284
  content=content,
281
285
  response_id=am.response_id,
@@ -288,7 +292,6 @@ class Session(BaseModel):
288
292
  arguments=tc.arguments,
289
293
  response_id=tc.response_id,
290
294
  session_id=self.id,
291
- is_replay=True,
292
295
  )
293
296
  case model.ToolResultItem() as tr:
294
297
  yield events.ToolResultEvent(
@@ -298,9 +301,9 @@ class Session(BaseModel):
298
301
  ui_extra=tr.ui_extra,
299
302
  session_id=self.id,
300
303
  status=tr.status,
301
- is_replay=True,
304
+ task_metadata=tr.task_metadata,
302
305
  )
303
- # TODO: Replay Sub-Agent Events
306
+ yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
304
307
  case model.UserMessageItem() as um:
305
308
  yield events.UserMessageEvent(
306
309
  content=um.content or "",
@@ -333,6 +336,35 @@ class Session(BaseModel):
333
336
  case _:
334
337
  continue
335
338
  prev_item = it
339
+ yield events.TaskFinishEvent(session_id=self.id, task_result=last_assistant_content)
340
+
341
+ def _iter_sub_agent_history(
342
+ self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
343
+ ) -> Iterable[events.HistoryItemEvent]:
344
+ """Replay sub-agent session history when a tool result references it.
345
+
346
+ Sub-agent tool results embed a SessionIdUIExtra containing the child session ID.
347
+ When present, we load that session and yield its history events so replay/export
348
+ can show the full sub-agent transcript instead of only the summarized tool output.
349
+ """
350
+ ui_extra = tool_result.ui_extra
351
+ if not isinstance(ui_extra, model.SessionIdUIExtra):
352
+ return
353
+
354
+ session_id = ui_extra.session_id
355
+ if not session_id or session_id == self.id:
356
+ return
357
+ if session_id in seen_sub_agent_sessions:
358
+ return
359
+
360
+ seen_sub_agent_sessions.add(session_id)
361
+
362
+ try:
363
+ sub_session = Session.load(session_id)
364
+ except Exception:
365
+ return
366
+
367
+ yield from sub_session.get_history_item()
336
368
 
337
369
  class SessionMetaBrief(BaseModel):
338
370
  id: str
@@ -886,6 +886,52 @@
886
886
  color: var(--text-dim);
887
887
  }
888
888
 
889
+ /* Sub-agent Session */
890
+ details.sub-agent-session {
891
+ background: transparent;
892
+ margin: 16px 0;
893
+ border-left: 3px solid var(--accent);
894
+ padding-left: 16px;
895
+ }
896
+ details.sub-agent-session > summary {
897
+ padding: 4px 0;
898
+ font-family: var(--font-mono);
899
+ font-size: var(--font-size-sm);
900
+ text-transform: uppercase;
901
+ font-weight: var(--font-weight-bold);
902
+ cursor: pointer;
903
+ user-select: none;
904
+ list-style: none;
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 8px;
908
+ min-height: 24px;
909
+ line-height: 1.2;
910
+ color: var(--text-dim);
911
+ transition: color 0.2s;
912
+ }
913
+ details.sub-agent-session > summary:hover,
914
+ details.sub-agent-session[open] > summary {
915
+ color: var(--text);
916
+ }
917
+ details.sub-agent-session > summary::-webkit-details-marker {
918
+ display: none;
919
+ }
920
+ details.sub-agent-session > summary::before {
921
+ content: "[+]";
922
+ color: var(--accent);
923
+ font-family: var(--font-mono);
924
+ margin-right: 4px;
925
+ display: inline-block;
926
+ min-width: 24px;
927
+ }
928
+ details.sub-agent-session[open] > summary::before {
929
+ content: "[-]";
930
+ }
931
+ details.sub-agent-session > .sub-agent-content {
932
+ padding: 8px 0 0 0;
933
+ }
934
+
889
935
  /* Scroll to Bottom Button */
890
936
  .scroll-btn {
891
937
  position: fixed;
@@ -1,3 +1,3 @@
1
- from .log import DebugType, is_debug_enabled, log, log_debug, logger, set_debug_logging
1
+ from .log import DebugType, is_debug_enabled, log, log_debug, logger, prepare_debug_log_file, set_debug_logging
2
2
 
3
- __all__ = ["DebugType", "is_debug_enabled", "log", "log_debug", "logger", "set_debug_logging"]
3
+ __all__ = ["DebugType", "is_debug_enabled", "log", "log_debug", "logger", "prepare_debug_log_file", "set_debug_logging"]
klaude_code/trace/log.py CHANGED
@@ -1,7 +1,13 @@
1
+ import gzip
1
2
  import logging
3
+ import os
4
+ import shutil
5
+ import subprocess
2
6
  from collections.abc import Iterable
7
+ from datetime import datetime, timedelta
3
8
  from enum import Enum
4
9
  from logging.handlers import RotatingFileHandler
10
+ from pathlib import Path
5
11
 
6
12
  from rich.console import Console
7
13
  from rich.logging import RichHandler
@@ -49,6 +55,26 @@ _file_handler: RotatingFileHandler | None = None
49
55
  _console_handler: RichHandler | None = None
50
56
  _debug_filter: DebugTypeFilter | None = None
51
57
  _debug_enabled = False
58
+ _current_log_file: Path | None = None
59
+
60
+ LOG_RETENTION_DAYS = 3
61
+ LOG_MAX_TOTAL_BYTES = 200 * 1024 * 1024
62
+
63
+
64
+ class GzipRotatingFileHandler(RotatingFileHandler):
65
+ """Rotating file handler that gzips rolled files."""
66
+
67
+ def rotation_filename(self, default_name: str) -> str:
68
+ """Append .gz to rotation targets."""
69
+
70
+ return f"{default_name}.gz"
71
+
72
+ def rotate(self, source: str, dest: str) -> None:
73
+ """Compress the rotated file and remove the original."""
74
+
75
+ with open(source, "rb") as source_file, gzip.open(dest, "wb") as dest_file:
76
+ shutil.copyfileobj(source_file, dest_file)
77
+ Path(source).unlink(missing_ok=True)
52
78
 
53
79
 
54
80
  def set_debug_logging(
@@ -66,7 +92,7 @@ def set_debug_logging(
66
92
  log_file: Path to the log file (default: debug.log)
67
93
  filters: Set of DebugType to include; None means all types
68
94
  """
69
- global _file_handler, _console_handler, _debug_filter, _debug_enabled
95
+ global _file_handler, _console_handler, _debug_filter, _debug_enabled, _current_log_file
70
96
 
71
97
  _debug_enabled = enabled
72
98
 
@@ -80,6 +106,7 @@ def set_debug_logging(
80
106
  _console_handler = None
81
107
 
82
108
  if not enabled:
109
+ _current_log_file = None
83
110
  return
84
111
 
85
112
  # Create filter
@@ -87,10 +114,19 @@ def set_debug_logging(
87
114
 
88
115
  # Determine output mode
89
116
  use_file = write_to_file if write_to_file is not None else True
90
- file_path = log_file if log_file is not None else const.DEFAULT_DEBUG_LOG_FILE
91
-
92
117
  if use_file:
93
- _file_handler = RotatingFileHandler(
118
+ if _current_log_file is None:
119
+ _current_log_file = _resolve_log_file(log_file)
120
+ file_path = _current_log_file
121
+ else:
122
+ _current_log_file = None
123
+ file_path = None
124
+
125
+ if use_file and file_path is not None:
126
+ _prune_old_logs(const.DEFAULT_DEBUG_LOG_DIR, LOG_RETENTION_DAYS, LOG_MAX_TOTAL_BYTES)
127
+
128
+ if use_file and file_path is not None:
129
+ _file_handler = GzipRotatingFileHandler(
94
130
  file_path,
95
131
  maxBytes=const.LOG_MAX_BYTES,
96
132
  backupCount=const.LOG_BACKUP_COUNT,
@@ -166,3 +202,106 @@ def _build_message(objects: Iterable[str | tuple[str, str]]) -> str:
166
202
  def is_debug_enabled() -> bool:
167
203
  """Check if debug logging is currently enabled."""
168
204
  return _debug_enabled
205
+
206
+
207
+ def prepare_debug_log_file(log_file: str | os.PathLike[str] | None = None) -> Path:
208
+ """Prepare and remember the log file path for this session."""
209
+
210
+ global _current_log_file
211
+ _current_log_file = _resolve_log_file(log_file)
212
+ return _current_log_file
213
+
214
+
215
+ def get_current_log_file() -> Path | None:
216
+ """Return the currently active log file path, if any."""
217
+
218
+ return _current_log_file
219
+
220
+
221
+ def _resolve_log_file(log_file: str | os.PathLike[str] | None) -> Path:
222
+ """Resolve the log file path and ensure directories exist."""
223
+
224
+ if log_file:
225
+ path = Path(log_file).expanduser()
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ return path
228
+ else:
229
+ path = _build_default_log_file_path()
230
+
231
+ path.parent.mkdir(parents=True, exist_ok=True)
232
+ path.touch(exist_ok=True)
233
+ _refresh_latest_symlink(path)
234
+ return path
235
+
236
+
237
+ def _build_default_log_file_path() -> Path:
238
+ """Build a per-session log path under the default log directory."""
239
+
240
+ now = datetime.now()
241
+ session_dir = const.DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
242
+ session_dir.mkdir(parents=True, exist_ok=True)
243
+ filename = f"{now.strftime('%H%M%S')}-{os.getpid()}.log"
244
+ return session_dir / filename
245
+
246
+
247
+ def _refresh_latest_symlink(target: Path) -> None:
248
+ """Point the debug.log symlink at the latest session file."""
249
+
250
+ latest = const.DEFAULT_DEBUG_LOG_FILE
251
+ try:
252
+ latest.unlink(missing_ok=True)
253
+ latest.symlink_to(target)
254
+ except OSError:
255
+ # Non-blocking best-effort; logging should still proceed
256
+ return
257
+
258
+
259
+ def _prune_old_logs(log_root: Path, keep_days: int, max_total_bytes: int) -> None:
260
+ """Remove logs older than keep_days or when exceeding max_total_bytes."""
261
+
262
+ if not log_root.exists():
263
+ return
264
+
265
+ cutoff = datetime.now() - timedelta(days=keep_days)
266
+ files: list[Path] = [p for p in log_root.rglob("*") if p.is_file() and not p.is_symlink()]
267
+
268
+ # Remove by age
269
+ for path in files:
270
+ try:
271
+ mtime = datetime.fromtimestamp(path.stat().st_mtime)
272
+ except OSError:
273
+ continue
274
+ if mtime < cutoff:
275
+ _trash_path(path)
276
+
277
+ # Recompute remaining files and sizes
278
+ remaining: list[tuple[Path, float, int]] = []
279
+ total_size = 0
280
+ for path in log_root.rglob("*"):
281
+ if not path.is_file() or path.is_symlink():
282
+ continue
283
+ try:
284
+ stat = path.stat()
285
+ except OSError:
286
+ continue
287
+ remaining.append((path, stat.st_mtime, stat.st_size))
288
+ total_size += stat.st_size
289
+
290
+ if total_size <= max_total_bytes:
291
+ return
292
+
293
+ remaining.sort(key=lambda item: item[1])
294
+ for path, _, size in remaining:
295
+ _trash_path(path)
296
+ total_size -= size
297
+ if total_size <= max_total_bytes:
298
+ break
299
+
300
+
301
+ def _trash_path(path: Path) -> None:
302
+ """Send a path to trash, falling back to unlink if trash is unavailable."""
303
+
304
+ try:
305
+ subprocess.run(["trash", str(path)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
306
+ except FileNotFoundError:
307
+ path.unlink(missing_ok=True)
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from typing import override
2
3
 
3
4
  from klaude_code import const
@@ -10,7 +11,7 @@ class DebugEventDisplay(DisplayABC):
10
11
  def __init__(
11
12
  self,
12
13
  wrapped_display: DisplayABC | None = None,
13
- log_file: str = const.DEFAULT_DEBUG_LOG_FILE,
14
+ log_file: str | os.PathLike[str] = const.DEFAULT_DEBUG_LOG_FILE,
14
15
  ):
15
16
  self.wrapped_display = wrapped_display
16
17
  self.log_file = log_file
@@ -67,12 +67,12 @@ class _SlashCommandCompleter(Completer):
67
67
  ) -> Iterable[Completion]:
68
68
  # Only complete on first line
69
69
  if document.cursor_position_row != 0:
70
- return iter([])
70
+ return
71
71
 
72
72
  text_before = document.current_line_before_cursor
73
73
  m = self._SLASH_TOKEN_RE.search(text_before)
74
74
  if not m:
75
- return iter([])
75
+ return
76
76
 
77
77
  frag = m.group("frag")
78
78
  token_start = len(text_before) - len(f"/{frag}")
@@ -89,7 +89,7 @@ class _SlashCommandCompleter(Completer):
89
89
  matched.append((cmd_name, cmd_obj, hint))
90
90
 
91
91
  if not matched:
92
- return iter([])
92
+ return
93
93
 
94
94
  # Calculate max width for alignment
95
95
  # Find the longest command+hint length