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.
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +88 -0
- klaude_code/cli/debug.py +72 -0
- klaude_code/cli/main.py +31 -142
- klaude_code/cli/runtime.py +0 -31
- klaude_code/cli/session_cmd.py +3 -1
- klaude_code/command/model_cmd.py +1 -1
- klaude_code/config/__init__.py +0 -4
- klaude_code/config/config.py +31 -4
- klaude_code/const/__init__.py +8 -3
- klaude_code/core/agent.py +1 -1
- klaude_code/core/prompt.py +10 -5
- klaude_code/llm/client.py +4 -0
- klaude_code/llm/openrouter/client.py +5 -4
- klaude_code/protocol/events.py +2 -2
- klaude_code/protocol/sub_agent.py +0 -1
- klaude_code/session/export.py +58 -0
- klaude_code/session/session.py +35 -3
- klaude_code/session/templates/export_session.html +46 -0
- klaude_code/trace/__init__.py +2 -2
- klaude_code/trace/log.py +143 -4
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/completers.py +3 -3
- klaude_code/ui/modes/repl/renderer.py +50 -61
- klaude_code/ui/renderers/tools.py +4 -0
- klaude_code/ui/rich/theme.py +1 -1
- {klaude_code-1.2.13.dist-info → klaude_code-1.2.14.dist-info}/METADATA +1 -1
- {klaude_code-1.2.13.dist-info → klaude_code-1.2.14.dist-info}/RECORD +30 -27
- {klaude_code-1.2.13.dist-info → klaude_code-1.2.14.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.13.dist-info → klaude_code-1.2.14.dist-info}/entry_points.txt +0 -0
klaude_code/core/prompt.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
@@ -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:
|
klaude_code/protocol/events.py
CHANGED
|
@@ -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
|
|
klaude_code/session/export.py
CHANGED
|
@@ -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,
|
klaude_code/session/session.py
CHANGED
|
@@ -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
|
-
|
|
304
|
+
task_metadata=tr.task_metadata,
|
|
302
305
|
)
|
|
303
|
-
|
|
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;
|
klaude_code/trace/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
92
|
+
return
|
|
93
93
|
|
|
94
94
|
# Calculate max width for alignment
|
|
95
95
|
# Find the longest command+hint length
|