klaude-code 2.9.1__py3-none-any.whl → 2.10.1__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/app/runtime.py +5 -1
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/cli/main.py +10 -0
- klaude_code/config/assets/builtin_config.yaml +15 -14
- klaude_code/const.py +4 -3
- klaude_code/core/agent_profile.py +23 -0
- klaude_code/core/bash_mode.py +276 -0
- klaude_code/core/executor.py +40 -7
- klaude_code/core/manager/llm_clients.py +1 -0
- klaude_code/core/manager/llm_clients_builder.py +2 -2
- klaude_code/core/memory.py +140 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +2 -2
- klaude_code/core/reminders.py +17 -89
- klaude_code/core/tool/offload.py +4 -4
- klaude_code/core/tool/web/web_fetch_tool.md +2 -1
- klaude_code/core/tool/web/web_fetch_tool.py +1 -1
- klaude_code/core/turn.py +9 -4
- klaude_code/protocol/events.py +17 -0
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/templates/mermaid_viewer.html +85 -0
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/metadata.py +28 -25
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +56 -124
- klaude_code/tui/components/rich/theme.py +22 -12
- klaude_code/tui/components/thinking.py +0 -35
- klaude_code/tui/components/tools.py +4 -2
- klaude_code/tui/components/user_input.py +49 -59
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +14 -6
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +57 -31
- klaude_code/tui/machine.py +108 -28
- klaude_code/tui/renderer.py +117 -19
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/notifier.py +11 -12
- klaude_code/tui/terminal/selector.py +1 -1
- klaude_code/ui/terminal/title.py +4 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/METADATA +1 -1
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/RECORD +48 -47
- klaude_code/tui/components/assistant.py +0 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/WHEEL +0 -0
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/entry_points.txt +0 -0
klaude_code/app/runtime.py
CHANGED
|
@@ -12,6 +12,7 @@ from klaude_code.core.agent import Agent
|
|
|
12
12
|
from klaude_code.core.agent_profile import (
|
|
13
13
|
DefaultModelProfileProvider,
|
|
14
14
|
VanillaModelProfileProvider,
|
|
15
|
+
WebModelProfileProvider,
|
|
15
16
|
)
|
|
16
17
|
from klaude_code.core.executor import Executor
|
|
17
18
|
from klaude_code.core.manager import build_llm_clients
|
|
@@ -27,6 +28,7 @@ class AppInitConfig:
|
|
|
27
28
|
model: str | None
|
|
28
29
|
debug: bool
|
|
29
30
|
vanilla: bool
|
|
31
|
+
web: bool = False
|
|
30
32
|
debug_filters: set[DebugType] | None = None
|
|
31
33
|
|
|
32
34
|
|
|
@@ -74,6 +76,8 @@ async def initialize_app_components(
|
|
|
74
76
|
|
|
75
77
|
if init_config.vanilla:
|
|
76
78
|
model_profile_provider = VanillaModelProfileProvider()
|
|
79
|
+
elif init_config.web:
|
|
80
|
+
model_profile_provider = WebModelProfileProvider(config=config)
|
|
77
81
|
else:
|
|
78
82
|
model_profile_provider = DefaultModelProfileProvider(config=config)
|
|
79
83
|
|
|
@@ -87,7 +91,7 @@ async def initialize_app_components(
|
|
|
87
91
|
)
|
|
88
92
|
|
|
89
93
|
if on_model_change is not None:
|
|
90
|
-
on_model_change(llm_clients.
|
|
94
|
+
on_model_change(llm_clients.main_model_alias)
|
|
91
95
|
|
|
92
96
|
executor_task = asyncio.create_task(executor.start())
|
|
93
97
|
|
klaude_code/cli/cost_cmd.py
CHANGED
|
@@ -343,7 +343,7 @@ def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
|
|
|
343
343
|
sub_list = list(group.sub_providers.values())
|
|
344
344
|
for sub_idx, sub_group in enumerate(sub_list):
|
|
345
345
|
is_last_sub = sub_idx == len(sub_list) - 1
|
|
346
|
-
sub_prefix = "
|
|
346
|
+
sub_prefix = " ╰─ " if is_last_sub else " ├─ "
|
|
347
347
|
|
|
348
348
|
# Sub-provider row
|
|
349
349
|
add_stats_row(sub_group.total, prefix=sub_prefix, bold=True)
|
|
@@ -353,15 +353,15 @@ def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
|
|
|
353
353
|
is_last_model = model_idx == len(sub_group.models) - 1
|
|
354
354
|
# Indent based on whether sub-provider is last
|
|
355
355
|
if is_last_sub:
|
|
356
|
-
model_prefix = "
|
|
356
|
+
model_prefix = " ╰─ " if is_last_model else " ├─ "
|
|
357
357
|
else:
|
|
358
|
-
model_prefix = " │
|
|
358
|
+
model_prefix = " │ ╰─ " if is_last_model else " │ ├─ "
|
|
359
359
|
add_stats_row(stats, prefix=model_prefix)
|
|
360
360
|
else:
|
|
361
361
|
# No sub-providers: render two-level tree (direct models)
|
|
362
362
|
for model_idx, stats in enumerate(group.models):
|
|
363
363
|
is_last_model = model_idx == len(group.models) - 1
|
|
364
|
-
model_prefix = "
|
|
364
|
+
model_prefix = " ╰─ " if is_last_model else " ├─ "
|
|
365
365
|
add_stats_row(stats, prefix=model_prefix)
|
|
366
366
|
|
|
367
367
|
if show_subtotal:
|
klaude_code/cli/list_model.py
CHANGED
|
@@ -338,7 +338,7 @@ def _build_models_table(
|
|
|
338
338
|
model_count = len(provider.model_list)
|
|
339
339
|
for i, model in enumerate(provider.model_list):
|
|
340
340
|
is_last = i == model_count - 1
|
|
341
|
-
prefix = "
|
|
341
|
+
prefix = " ╰─ " if is_last else " ├─ "
|
|
342
342
|
|
|
343
343
|
if provider_disabled:
|
|
344
344
|
name = Text.assemble(
|
|
@@ -439,7 +439,6 @@ def display_models_and_providers(config: Config, *, show_all: bool = False):
|
|
|
439
439
|
# Provider info panel
|
|
440
440
|
provider_panel = _build_provider_info_panel(provider, provider_available, disabled=provider.disabled)
|
|
441
441
|
console.print(provider_panel)
|
|
442
|
-
console.print()
|
|
443
442
|
|
|
444
443
|
# Models table for this provider
|
|
445
444
|
models_table = _build_models_table(provider, config)
|
klaude_code/cli/main.py
CHANGED
|
@@ -200,6 +200,11 @@ def main_callback(
|
|
|
200
200
|
help="Image generation mode (alias for --model banana)",
|
|
201
201
|
rich_help_panel="LLM",
|
|
202
202
|
),
|
|
203
|
+
web: bool = typer.Option(
|
|
204
|
+
False,
|
|
205
|
+
"--web",
|
|
206
|
+
help="Enable web tools (WebFetch, WebSearch) for the main agent",
|
|
207
|
+
),
|
|
203
208
|
version: bool = typer.Option(
|
|
204
209
|
False,
|
|
205
210
|
"--version",
|
|
@@ -218,6 +223,10 @@ def main_callback(
|
|
|
218
223
|
log(("Error: --banana cannot be combined with --vanilla", "red"))
|
|
219
224
|
raise typer.Exit(2)
|
|
220
225
|
|
|
226
|
+
if vanilla and web:
|
|
227
|
+
log(("Error: --web cannot be combined with --vanilla", "red"))
|
|
228
|
+
raise typer.Exit(2)
|
|
229
|
+
|
|
221
230
|
resume_by_id_value = resume_by_id.strip() if resume_by_id is not None else None
|
|
222
231
|
if resume_by_id_value == "":
|
|
223
232
|
log(("Error: --resume <id> cannot be empty", "red"))
|
|
@@ -347,6 +356,7 @@ def main_callback(
|
|
|
347
356
|
model=chosen_model,
|
|
348
357
|
debug=debug_enabled,
|
|
349
358
|
vanilla=vanilla,
|
|
359
|
+
web=web,
|
|
350
360
|
debug_filters=debug_filters,
|
|
351
361
|
)
|
|
352
362
|
|
|
@@ -57,22 +57,14 @@ provider_list:
|
|
|
57
57
|
reasoning_summary: concise
|
|
58
58
|
cost: {input: 1.75, output: 14, cache_read: 0.17}
|
|
59
59
|
|
|
60
|
-
- model_name: gpt-5.2-
|
|
61
|
-
model_id: gpt-5.2
|
|
62
|
-
context_limit: 400000
|
|
63
|
-
verbosity: low
|
|
60
|
+
- model_name: gpt-5.2-codex
|
|
61
|
+
model_id: gpt-5.2-codex
|
|
64
62
|
thinking:
|
|
65
|
-
reasoning_effort:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- model_name: gpt-5.1-codex-max
|
|
69
|
-
model_id: gpt-5.1-codex-max
|
|
70
|
-
max_tokens: 128000
|
|
63
|
+
reasoning_effort: high
|
|
64
|
+
reasoning_summary: auto
|
|
71
65
|
context_limit: 400000
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
reasoning_summary: concise
|
|
75
|
-
cost: {input: 1.25, output: 10, cache_read: 0.13}
|
|
66
|
+
max_tokens: 128000
|
|
67
|
+
cost: {input: 1.75, output: 14, cache_read: 0.17}
|
|
76
68
|
|
|
77
69
|
|
|
78
70
|
- provider_name: openrouter
|
|
@@ -80,6 +72,15 @@ provider_list:
|
|
|
80
72
|
api_key: ${OPENROUTER_API_KEY}
|
|
81
73
|
model_list:
|
|
82
74
|
|
|
75
|
+
- model_name: gpt-5.2-codex
|
|
76
|
+
model_id: gpt-5.2-codex
|
|
77
|
+
thinking:
|
|
78
|
+
reasoning_effort: high
|
|
79
|
+
reasoning_summary: auto
|
|
80
|
+
context_limit: 400000
|
|
81
|
+
max_tokens: 128000
|
|
82
|
+
cost: {input: 1.75, output: 14, cache_read: 0.17}
|
|
83
|
+
|
|
83
84
|
- model_name: gpt-5.2-high
|
|
84
85
|
model_id: openai/gpt-5.2
|
|
85
86
|
max_tokens: 128000
|
klaude_code/const.py
CHANGED
|
@@ -71,7 +71,6 @@ DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS = 2048 # Default thinking budget token
|
|
|
71
71
|
|
|
72
72
|
TODO_REMINDER_TOOL_CALL_THRESHOLD = 10 # Tool call count threshold for todo reminder
|
|
73
73
|
REMINDER_COOLDOWN_TURNS = 3 # Cooldown turns between reminder triggers
|
|
74
|
-
MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"] # Memory file names to search for
|
|
75
74
|
|
|
76
75
|
|
|
77
76
|
# =============================================================================
|
|
@@ -92,6 +91,7 @@ BINARY_CHECK_SIZE = 8192 # Bytes to check for binary file detection
|
|
|
92
91
|
|
|
93
92
|
BASH_DEFAULT_TIMEOUT_MS = 120000 # Default timeout for bash commands (milliseconds)
|
|
94
93
|
BASH_TERMINATE_TIMEOUT_SEC = 1.0 # Timeout before escalating to SIGKILL (seconds)
|
|
94
|
+
BASH_MODE_SESSION_OUTPUT_MAX_BYTES = 200 * 1024 * 1024 # Max command output captured for session history
|
|
95
95
|
|
|
96
96
|
|
|
97
97
|
# =============================================================================
|
|
@@ -156,8 +156,8 @@ CROP_ABOVE_LIVE_REFRESH_PER_SECOND = 4.0 # CropAboveLive default refresh rate
|
|
|
156
156
|
MARKDOWN_STREAM_LIVE_REPAINT_ENABLED = True # Enable live area for streaming markdown
|
|
157
157
|
MARKDOWN_STREAM_SYNCHRONIZED_OUTPUT_ENABLED = True # Use terminal "Synchronized Output" to reduce flicker
|
|
158
158
|
STREAM_MAX_HEIGHT_SHRINK_RESET_LINES = 20 # Reset stream height ceiling after this shrinkage
|
|
159
|
-
MARKDOWN_LEFT_MARGIN =
|
|
160
|
-
MARKDOWN_RIGHT_MARGIN =
|
|
159
|
+
MARKDOWN_LEFT_MARGIN = 0 # Left margin (columns) for markdown rendering
|
|
160
|
+
MARKDOWN_RIGHT_MARGIN = 0 # Right margin (columns) for markdown rendering
|
|
161
161
|
|
|
162
162
|
|
|
163
163
|
# =============================================================================
|
|
@@ -171,6 +171,7 @@ STATUS_WAITING_TEXT = "Loading …"
|
|
|
171
171
|
STATUS_THINKING_TEXT = "Thinking …"
|
|
172
172
|
STATUS_COMPOSING_TEXT = "Composing"
|
|
173
173
|
STATUS_COMPACTING_TEXT = "Compacting"
|
|
174
|
+
STATUS_RUNNING_TEXT = "Running …"
|
|
174
175
|
|
|
175
176
|
# Backwards-compatible alias for the default spinner status text.
|
|
176
177
|
STATUS_DEFAULT_TEXT = STATUS_WAITING_TEXT
|
|
@@ -305,3 +305,26 @@ class VanillaModelProfileProvider(ModelProfileProvider):
|
|
|
305
305
|
if output_schema:
|
|
306
306
|
return with_structured_output(profile, output_schema)
|
|
307
307
|
return profile
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class WebModelProfileProvider(DefaultModelProfileProvider):
|
|
311
|
+
"""Provider that adds web tools to the main agent."""
|
|
312
|
+
|
|
313
|
+
def build_profile(
|
|
314
|
+
self,
|
|
315
|
+
llm_client: LLMClientABC,
|
|
316
|
+
sub_agent_type: tools.SubAgentType | None = None,
|
|
317
|
+
*,
|
|
318
|
+
output_schema: dict[str, Any] | None = None,
|
|
319
|
+
) -> AgentProfile:
|
|
320
|
+
profile = super().build_profile(llm_client, sub_agent_type, output_schema=output_schema)
|
|
321
|
+
# Only add web tools for main agent (not sub-agents)
|
|
322
|
+
if sub_agent_type is None:
|
|
323
|
+
web_tools = get_tool_schemas([tools.WEB_FETCH, tools.WEB_SEARCH])
|
|
324
|
+
return AgentProfile(
|
|
325
|
+
llm_client=profile.llm_client,
|
|
326
|
+
system_prompt=profile.system_prompt,
|
|
327
|
+
tools=[*profile.tools, *web_tools],
|
|
328
|
+
reminders=profile.reminders,
|
|
329
|
+
)
|
|
330
|
+
return profile
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Bash-mode execution helpers.
|
|
2
|
+
|
|
3
|
+
This module provides the implementation for running non-interactive shell commands
|
|
4
|
+
with streaming output to the UI, plus session history recording.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
from klaude_code.const import BASH_MODE_SESSION_OUTPUT_MAX_BYTES, BASH_TERMINATE_TIMEOUT_SEC, TOOL_OUTPUT_TRUNCATION_DIR
|
|
24
|
+
from klaude_code.core.tool.offload import offload_tool_output
|
|
25
|
+
from klaude_code.protocol import events, message
|
|
26
|
+
from klaude_code.session.session import Session
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _BashModeToolCall:
|
|
31
|
+
tool_name: str = "Bash"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_ANSI_ESCAPE_RE = re.compile(
|
|
35
|
+
r"""
|
|
36
|
+
\x1B
|
|
37
|
+
(?:
|
|
38
|
+
\[[0-?]*[ -/]*[@-~] | # CSI sequences
|
|
39
|
+
\][0-?]*.*?(?:\x07|\x1B\\) | # OSC sequences
|
|
40
|
+
P.*?(?:\x07|\x1B\\) | # DCS sequences
|
|
41
|
+
_.*?(?:\x07|\x1B\\) | # APC sequences
|
|
42
|
+
\^.*?(?:\x07|\x1B\\) | # PM sequences
|
|
43
|
+
[@-Z\\-_] # 2-char sequences
|
|
44
|
+
)
|
|
45
|
+
""",
|
|
46
|
+
re.VERBOSE | re.DOTALL,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_inline_code(text: str) -> str:
|
|
51
|
+
if not text:
|
|
52
|
+
return "``"
|
|
53
|
+
max_run = 0
|
|
54
|
+
run = 0
|
|
55
|
+
for ch in text:
|
|
56
|
+
if ch == "`":
|
|
57
|
+
run += 1
|
|
58
|
+
max_run = max(max_run, run)
|
|
59
|
+
else:
|
|
60
|
+
run = 0
|
|
61
|
+
fence = "`" * (max_run + 1)
|
|
62
|
+
return f"{fence}{text}{fence}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_shell_command(command_text: str) -> list[str]:
|
|
66
|
+
# Use the user's default shell when possible.
|
|
67
|
+
# - macOS/Linux: $SHELL (supports bash/zsh/fish)
|
|
68
|
+
# - Windows: prefer pwsh/powershell
|
|
69
|
+
if sys.platform == "win32": # pragma: no cover
|
|
70
|
+
exe = "pwsh" if shutil.which("pwsh") else "powershell"
|
|
71
|
+
return [exe, "-NoProfile", "-Command", command_text]
|
|
72
|
+
|
|
73
|
+
shell_path = os.environ.get("SHELL")
|
|
74
|
+
shell_name = Path(shell_path).name.lower() if shell_path else ""
|
|
75
|
+
if shell_path and shell_name in {"bash", "zsh", "fish"}:
|
|
76
|
+
# Use -lic to load both login profile and interactive config (e.g. aliases from .zshrc)
|
|
77
|
+
return [shell_path, "-lic", command_text]
|
|
78
|
+
return ["bash", "-lic", command_text]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
|
82
|
+
if proc.returncode is not None:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
if os.name == "posix":
|
|
87
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
88
|
+
else: # pragma: no cover
|
|
89
|
+
proc.terminate()
|
|
90
|
+
except ProcessLookupError:
|
|
91
|
+
return
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
with contextlib.suppress(Exception):
|
|
96
|
+
await asyncio.wait_for(proc.wait(), timeout=BASH_TERMINATE_TIMEOUT_SEC)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
with contextlib.suppress(Exception):
|
|
100
|
+
if os.name == "posix":
|
|
101
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
102
|
+
else: # pragma: no cover
|
|
103
|
+
proc.kill()
|
|
104
|
+
with contextlib.suppress(Exception):
|
|
105
|
+
await asyncio.wait_for(proc.wait(), timeout=BASH_TERMINATE_TIMEOUT_SEC)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _emit_clean_chunk(
|
|
109
|
+
*,
|
|
110
|
+
emit_event: Callable[[events.Event], Awaitable[None]],
|
|
111
|
+
session_id: str,
|
|
112
|
+
chunk: str,
|
|
113
|
+
out_file: TextIO,
|
|
114
|
+
) -> None:
|
|
115
|
+
if not chunk:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
cleaned = _ANSI_ESCAPE_RE.sub("", chunk)
|
|
119
|
+
if cleaned:
|
|
120
|
+
await emit_event(events.BashCommandOutputDeltaEvent(session_id=session_id, content=cleaned))
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
out_file.write(cleaned)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def run_bash_command(
|
|
126
|
+
*,
|
|
127
|
+
emit_event: Callable[[events.Event], Awaitable[None]],
|
|
128
|
+
session: Session,
|
|
129
|
+
session_id: str,
|
|
130
|
+
command: str,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Run a non-interactive bash command with streaming output to the UI.
|
|
133
|
+
|
|
134
|
+
The full (cleaned) output is appended to session history in a single UserMessage
|
|
135
|
+
as: `Ran <command>` plus truncated output via offload strategy.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
await emit_event(events.BashCommandStartEvent(session_id=session_id, command=command))
|
|
139
|
+
|
|
140
|
+
# Create a log file to support large outputs without holding everything in memory.
|
|
141
|
+
# Use TOOL_OUTPUT_TRUNCATION_DIR (system temp) for consistency with offload.
|
|
142
|
+
tmp_root = Path(TOOL_OUTPUT_TRUNCATION_DIR)
|
|
143
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
log_path = tmp_root / f"klaude-bash-mode-{secrets.token_hex(8)}.log"
|
|
145
|
+
|
|
146
|
+
env = os.environ.copy()
|
|
147
|
+
env.update(
|
|
148
|
+
{
|
|
149
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
150
|
+
"PAGER": "cat",
|
|
151
|
+
"GIT_PAGER": "cat",
|
|
152
|
+
"EDITOR": "true",
|
|
153
|
+
"VISUAL": "true",
|
|
154
|
+
"GIT_EDITOR": "true",
|
|
155
|
+
"JJ_EDITOR": "true",
|
|
156
|
+
"TERM": "dumb",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
proc: asyncio.subprocess.Process | None = None
|
|
161
|
+
cancelled = False
|
|
162
|
+
exit_code: int | None = None
|
|
163
|
+
|
|
164
|
+
# Hold back any trailing ESC-started sequence to avoid leaking control codes
|
|
165
|
+
# when the subprocess output is chunked.
|
|
166
|
+
pending = ""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
kwargs: dict[str, object] = {
|
|
170
|
+
"stdin": asyncio.subprocess.DEVNULL,
|
|
171
|
+
"stdout": asyncio.subprocess.PIPE,
|
|
172
|
+
"stderr": asyncio.subprocess.STDOUT,
|
|
173
|
+
"env": env,
|
|
174
|
+
}
|
|
175
|
+
if os.name == "posix":
|
|
176
|
+
kwargs["start_new_session"] = True
|
|
177
|
+
elif os.name == "nt": # pragma: no cover
|
|
178
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
179
|
+
|
|
180
|
+
shell_argv = _resolve_shell_command(command)
|
|
181
|
+
proc = await asyncio.create_subprocess_exec(*shell_argv, **kwargs) # type: ignore[arg-type]
|
|
182
|
+
assert proc.stdout is not None
|
|
183
|
+
|
|
184
|
+
with log_path.open("w", encoding="utf-8", errors="replace") as out_file:
|
|
185
|
+
while True:
|
|
186
|
+
data = await proc.stdout.read(4096)
|
|
187
|
+
if not data:
|
|
188
|
+
break
|
|
189
|
+
piece = data.decode(errors="replace")
|
|
190
|
+
pending += piece
|
|
191
|
+
|
|
192
|
+
# Keep from the last ESC onwards to avoid emitting incomplete sequences.
|
|
193
|
+
last_esc = pending.rfind("\x1b")
|
|
194
|
+
if last_esc == -1:
|
|
195
|
+
to_emit, pending = pending, ""
|
|
196
|
+
elif last_esc < len(pending) - 128:
|
|
197
|
+
to_emit, pending = pending[:last_esc], pending[last_esc:]
|
|
198
|
+
else:
|
|
199
|
+
# Wait for more bytes to complete the sequence.
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
await _emit_clean_chunk(
|
|
203
|
+
emit_event=emit_event,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
chunk=to_emit,
|
|
206
|
+
out_file=out_file,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if pending:
|
|
210
|
+
await _emit_clean_chunk(
|
|
211
|
+
emit_event=emit_event,
|
|
212
|
+
session_id=session_id,
|
|
213
|
+
chunk=pending,
|
|
214
|
+
out_file=out_file,
|
|
215
|
+
)
|
|
216
|
+
pending = ""
|
|
217
|
+
|
|
218
|
+
exit_code = await proc.wait()
|
|
219
|
+
|
|
220
|
+
except asyncio.CancelledError:
|
|
221
|
+
cancelled = True
|
|
222
|
+
if proc is not None:
|
|
223
|
+
with contextlib.suppress(Exception):
|
|
224
|
+
await asyncio.shield(_terminate_process(proc))
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
# Surface errors to the UI as a final line.
|
|
227
|
+
msg = f"Execution error: {exc.__class__.__name__} {exc}"
|
|
228
|
+
await emit_event(events.BashCommandOutputDeltaEvent(session_id=session_id, content=msg))
|
|
229
|
+
finally:
|
|
230
|
+
header = f"Ran {_format_inline_code(command)}"
|
|
231
|
+
|
|
232
|
+
record_lines: list[str] = [header]
|
|
233
|
+
if cancelled:
|
|
234
|
+
record_lines.append("\n(command cancelled)")
|
|
235
|
+
elif isinstance(exit_code, int) and exit_code != 0:
|
|
236
|
+
record_lines.append(f"\nCommand exited with code {exit_code}")
|
|
237
|
+
|
|
238
|
+
output_text = ""
|
|
239
|
+
output_note_added = False
|
|
240
|
+
try:
|
|
241
|
+
if log_path.exists() and log_path.stat().st_size > BASH_MODE_SESSION_OUTPUT_MAX_BYTES:
|
|
242
|
+
record_lines.append(
|
|
243
|
+
f"\n\n<system-reminder>Output truncated due to length. Full output saved to: {log_path} </system-reminder>"
|
|
244
|
+
)
|
|
245
|
+
output_note_added = True
|
|
246
|
+
else:
|
|
247
|
+
output_text = log_path.read_text("utf-8", errors="replace") if log_path.exists() else ""
|
|
248
|
+
except OSError:
|
|
249
|
+
output_text = ""
|
|
250
|
+
|
|
251
|
+
if output_text.strip() == "":
|
|
252
|
+
if not cancelled and not output_note_added:
|
|
253
|
+
record_lines.append("\n(no output)")
|
|
254
|
+
await emit_event(events.BashCommandOutputDeltaEvent(session_id=session_id, content="(no output)\n"))
|
|
255
|
+
else:
|
|
256
|
+
offloaded = offload_tool_output(output_text, _BashModeToolCall())
|
|
257
|
+
record_lines.append("\n\n" + offloaded.output)
|
|
258
|
+
|
|
259
|
+
# Always emit an end event so the renderer can finalize formatting.
|
|
260
|
+
await emit_event(
|
|
261
|
+
events.BashCommandEndEvent(
|
|
262
|
+
session_id=session_id,
|
|
263
|
+
exit_code=exit_code,
|
|
264
|
+
cancelled=cancelled,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
session.append_history(
|
|
268
|
+
[
|
|
269
|
+
message.UserMessage(
|
|
270
|
+
parts=message.parts_from_text_and_images(
|
|
271
|
+
"".join(record_lines).rstrip(),
|
|
272
|
+
None,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
)
|
klaude_code/core/executor.py
CHANGED
|
@@ -18,9 +18,11 @@ from klaude_code.config import load_config
|
|
|
18
18
|
from klaude_code.config.sub_agent_model_helper import SubAgentModelHelper
|
|
19
19
|
from klaude_code.core.agent import Agent
|
|
20
20
|
from klaude_code.core.agent_profile import DefaultModelProfileProvider, ModelProfileProvider
|
|
21
|
+
from klaude_code.core.bash_mode import run_bash_command
|
|
21
22
|
from klaude_code.core.compaction import CompactionReason, run_compaction
|
|
22
23
|
from klaude_code.core.loaded_skills import get_loaded_skill_names_by_location
|
|
23
24
|
from klaude_code.core.manager import LLMClients, SubAgentManager
|
|
25
|
+
from klaude_code.core.memory import get_existing_memory_paths_by_location
|
|
24
26
|
from klaude_code.llm.registry import create_llm_client
|
|
25
27
|
from klaude_code.log import DebugType, log_debug
|
|
26
28
|
from klaude_code.protocol import commands, events, message, model, op
|
|
@@ -134,18 +136,19 @@ class AgentRuntime:
|
|
|
134
136
|
compact_llm_client=self._llm_clients.compact,
|
|
135
137
|
)
|
|
136
138
|
|
|
137
|
-
async for evt in agent.replay_history():
|
|
138
|
-
await self._emit_event(evt)
|
|
139
|
-
|
|
140
139
|
await self._emit_event(
|
|
141
140
|
events.WelcomeEvent(
|
|
142
141
|
session_id=session.id,
|
|
143
142
|
work_dir=str(session.work_dir),
|
|
144
143
|
llm_config=self._llm_clients.main.get_llm_config(),
|
|
145
144
|
loaded_skills=get_loaded_skill_names_by_location(),
|
|
145
|
+
loaded_memories=get_existing_memory_paths_by_location(work_dir=session.work_dir),
|
|
146
146
|
)
|
|
147
147
|
)
|
|
148
148
|
|
|
149
|
+
async for evt in agent.replay_history():
|
|
150
|
+
await self._emit_event(evt)
|
|
151
|
+
|
|
149
152
|
self._agent = agent
|
|
150
153
|
log_debug(
|
|
151
154
|
f"Initialized agent for session: {session.id}",
|
|
@@ -179,6 +182,23 @@ class AgentRuntime:
|
|
|
179
182
|
)
|
|
180
183
|
self._task_manager.register(operation.id, task, operation.session_id)
|
|
181
184
|
|
|
185
|
+
async def run_bash(self, operation: op.RunBashOperation) -> None:
|
|
186
|
+
agent = await self.ensure_agent(operation.session_id)
|
|
187
|
+
|
|
188
|
+
existing_active = self._task_manager.get(operation.id)
|
|
189
|
+
if existing_active is not None and not existing_active.task.done():
|
|
190
|
+
raise RuntimeError(f"Active task already registered for operation {operation.id}")
|
|
191
|
+
|
|
192
|
+
task: asyncio.Task[None] = asyncio.create_task(
|
|
193
|
+
self._run_bash_task(
|
|
194
|
+
session=agent.session,
|
|
195
|
+
command=operation.command,
|
|
196
|
+
task_id=operation.id,
|
|
197
|
+
session_id=operation.session_id,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
self._task_manager.register(operation.id, task, operation.session_id)
|
|
201
|
+
|
|
182
202
|
async def continue_agent(self, operation: op.ContinueAgentOperation) -> None:
|
|
183
203
|
"""Continue agent execution without adding a new user message."""
|
|
184
204
|
agent = await self.ensure_agent(operation.session_id)
|
|
@@ -230,6 +250,7 @@ class AgentRuntime:
|
|
|
230
250
|
work_dir=str(agent.session.work_dir),
|
|
231
251
|
llm_config=self._llm_clients.main.get_llm_config(),
|
|
232
252
|
loaded_skills=get_loaded_skill_names_by_location(),
|
|
253
|
+
loaded_memories=get_existing_memory_paths_by_location(work_dir=agent.session.work_dir),
|
|
233
254
|
)
|
|
234
255
|
)
|
|
235
256
|
|
|
@@ -249,18 +270,19 @@ class AgentRuntime:
|
|
|
249
270
|
compact_llm_client=self._llm_clients.compact,
|
|
250
271
|
)
|
|
251
272
|
|
|
252
|
-
async for evt in agent.replay_history():
|
|
253
|
-
await self._emit_event(evt)
|
|
254
|
-
|
|
255
273
|
await self._emit_event(
|
|
256
274
|
events.WelcomeEvent(
|
|
257
275
|
session_id=target_session.id,
|
|
258
276
|
work_dir=str(target_session.work_dir),
|
|
259
277
|
llm_config=self._llm_clients.main.get_llm_config(),
|
|
260
278
|
loaded_skills=get_loaded_skill_names_by_location(),
|
|
279
|
+
loaded_memories=get_existing_memory_paths_by_location(work_dir=target_session.work_dir),
|
|
261
280
|
)
|
|
262
281
|
)
|
|
263
282
|
|
|
283
|
+
async for evt in agent.replay_history():
|
|
284
|
+
await self._emit_event(evt)
|
|
285
|
+
|
|
264
286
|
self._agent = agent
|
|
265
287
|
log_debug(
|
|
266
288
|
f"Resumed session: {target_session.id}",
|
|
@@ -359,6 +381,14 @@ class AgentRuntime:
|
|
|
359
381
|
debug_type=DebugType.EXECUTION,
|
|
360
382
|
)
|
|
361
383
|
|
|
384
|
+
async def _run_bash_task(self, *, session: Session, command: str, task_id: str, session_id: str) -> None:
|
|
385
|
+
await run_bash_command(
|
|
386
|
+
emit_event=self._emit_event,
|
|
387
|
+
session=session,
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
command=command,
|
|
390
|
+
)
|
|
391
|
+
|
|
362
392
|
async def _run_compaction_task(
|
|
363
393
|
self,
|
|
364
394
|
agent: Agent,
|
|
@@ -467,7 +497,7 @@ class ModelSwitcher:
|
|
|
467
497
|
config.main_model = model_name
|
|
468
498
|
await config.save()
|
|
469
499
|
|
|
470
|
-
return llm_config,
|
|
500
|
+
return llm_config, model_name
|
|
471
501
|
|
|
472
502
|
def change_thinking(self, agent: Agent, *, thinking: Thinking) -> Thinking | None:
|
|
473
503
|
"""Apply thinking configuration to the agent's active LLM config and persisted session."""
|
|
@@ -540,6 +570,9 @@ class ExecutorContext:
|
|
|
540
570
|
async def handle_run_agent(self, operation: op.RunAgentOperation) -> None:
|
|
541
571
|
await self._agent_runtime.run_agent(operation)
|
|
542
572
|
|
|
573
|
+
async def handle_run_bash(self, operation: op.RunBashOperation) -> None:
|
|
574
|
+
await self._agent_runtime.run_bash(operation)
|
|
575
|
+
|
|
543
576
|
async def handle_continue_agent(self, operation: op.ContinueAgentOperation) -> None:
|
|
544
577
|
await self._agent_runtime.continue_agent(operation)
|
|
545
578
|
|
|
@@ -19,6 +19,7 @@ class LLMClients:
|
|
|
19
19
|
"""Container for LLM clients used by main agent and sub-agents."""
|
|
20
20
|
|
|
21
21
|
main: LLMClientABC
|
|
22
|
+
main_model_alias: str = ""
|
|
22
23
|
sub_clients: dict[SubAgentType, LLMClientABC] = dataclass_field(default_factory=_default_sub_clients)
|
|
23
24
|
compact: LLMClientABC | None = None
|
|
24
25
|
|
|
@@ -53,7 +53,7 @@ def build_llm_clients(
|
|
|
53
53
|
compact_client = create_llm_client(compact_llm_config)
|
|
54
54
|
|
|
55
55
|
if skip_sub_agents:
|
|
56
|
-
return LLMClients(main=main_client, compact=compact_client)
|
|
56
|
+
return LLMClients(main=main_client, main_model_alias=model_name, compact=compact_client)
|
|
57
57
|
|
|
58
58
|
helper = SubAgentModelHelper(config)
|
|
59
59
|
sub_agent_configs = helper.build_sub_agent_client_configs()
|
|
@@ -63,4 +63,4 @@ def build_llm_clients(
|
|
|
63
63
|
sub_llm_config = config.get_model_config(sub_model_name)
|
|
64
64
|
sub_clients[sub_agent_type] = create_llm_client(sub_llm_config)
|
|
65
65
|
|
|
66
|
-
return LLMClients(main=main_client, sub_clients=sub_clients, compact=compact_client)
|
|
66
|
+
return LLMClients(main=main_client, main_model_alias=model_name, sub_clients=sub_clients, compact=compact_client)
|