klaude-code 2.9.0__py3-none-any.whl → 2.10.0__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 +1 -1
- klaude_code/auth/antigravity/oauth.py +33 -29
- klaude_code/auth/claude/oauth.py +34 -49
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/config/assets/builtin_config.yaml +17 -0
- klaude_code/const.py +4 -3
- klaude_code/core/agent_profile.py +2 -5
- 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/reminders.py +17 -89
- klaude_code/core/task.py +1 -1
- klaude_code/core/tool/file/read_tool.py +13 -2
- klaude_code/core/tool/shell/bash_tool.py +1 -1
- klaude_code/core/turn.py +10 -4
- klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
- klaude_code/llm/input_common.py +18 -0
- klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
- klaude_code/llm/{codex → openai_codex}/client.py +3 -3
- klaude_code/llm/openai_compatible/client.py +3 -1
- klaude_code/llm/openai_compatible/stream.py +19 -9
- klaude_code/llm/{responses → openai_responses}/client.py +1 -1
- klaude_code/llm/registry.py +3 -3
- klaude_code/llm/stream_parts.py +3 -1
- klaude_code/llm/usage.py +1 -1
- klaude_code/protocol/events.py +17 -1
- klaude_code/protocol/message.py +1 -0
- klaude_code/protocol/model.py +14 -1
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/session.py +22 -1
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/bash_syntax.py +4 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/diffs.py +3 -2
- klaude_code/tui/components/metadata.py +23 -26
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +44 -28
- klaude_code/tui/components/rich/status.py +2 -2
- klaude_code/tui/components/rich/theme.py +28 -16
- klaude_code/tui/components/tools.py +23 -0
- klaude_code/tui/components/user_input.py +49 -58
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +15 -7
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +58 -31
- klaude_code/tui/machine.py +87 -49
- klaude_code/tui/renderer.py +148 -30
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/image.py +24 -3
- 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.0.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/RECORD +67 -66
- klaude_code/llm/bedrock/__init__.py +0 -3
- klaude_code/tui/components/assistant.py +0 -2
- /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
- /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
- /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
- /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
klaude_code/app/runtime.py
CHANGED
|
@@ -258,42 +258,46 @@ class AntigravityOAuth:
|
|
|
258
258
|
)
|
|
259
259
|
|
|
260
260
|
def refresh(self) -> AntigravityAuthState:
|
|
261
|
-
"""Refresh the access token using refresh token.
|
|
262
|
-
state = self.token_manager.get_state()
|
|
263
|
-
if state is None:
|
|
264
|
-
raise AntigravityNotLoggedInError("Not logged in to Antigravity. Run 'klaude login antigravity' first.")
|
|
261
|
+
"""Refresh the access token using refresh token with file locking.
|
|
265
262
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
"refresh_token": state.refresh_token,
|
|
270
|
-
"grant_type": "refresh_token",
|
|
271
|
-
}
|
|
263
|
+
Uses file locking to prevent multiple instances from refreshing simultaneously.
|
|
264
|
+
If another instance has already refreshed, returns the updated state.
|
|
265
|
+
"""
|
|
272
266
|
|
|
273
|
-
|
|
274
|
-
|
|
267
|
+
def do_refresh(current_state: AntigravityAuthState) -> AntigravityAuthState:
|
|
268
|
+
data = {
|
|
269
|
+
"client_id": CLIENT_ID,
|
|
270
|
+
"client_secret": CLIENT_SECRET,
|
|
271
|
+
"refresh_token": current_state.refresh_token,
|
|
272
|
+
"grant_type": "refresh_token",
|
|
273
|
+
}
|
|
275
274
|
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
with httpx.Client() as client:
|
|
276
|
+
response = client.post(TOKEN_URL, data=data, timeout=30)
|
|
278
277
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
refresh_token = tokens.get("refresh_token", state.refresh_token)
|
|
282
|
-
expires_in = tokens.get("expires_in", 3600)
|
|
278
|
+
if response.status_code != 200:
|
|
279
|
+
raise AntigravityTokenExpiredError(f"Token refresh failed: {response.text}")
|
|
283
280
|
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
tokens = response.json()
|
|
282
|
+
access_token = tokens["access_token"]
|
|
283
|
+
refresh_token = tokens.get("refresh_token", current_state.refresh_token)
|
|
284
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
286
285
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
286
|
+
# Calculate expiry time with 5 minute buffer
|
|
287
|
+
expires_at = int(time.time()) + expires_in - 300
|
|
288
|
+
|
|
289
|
+
return AntigravityAuthState(
|
|
290
|
+
access_token=access_token,
|
|
291
|
+
refresh_token=refresh_token,
|
|
292
|
+
expires_at=expires_at,
|
|
293
|
+
project_id=current_state.project_id,
|
|
294
|
+
email=current_state.email,
|
|
295
|
+
)
|
|
294
296
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
try:
|
|
298
|
+
return self.token_manager.refresh_with_lock(do_refresh)
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
raise AntigravityNotLoggedInError(str(e)) from e
|
|
297
301
|
|
|
298
302
|
def ensure_valid_token(self) -> tuple[str, str]:
|
|
299
303
|
"""Ensure we have a valid access token, refreshing if needed.
|
klaude_code/auth/claude/oauth.py
CHANGED
|
@@ -125,60 +125,45 @@ class ClaudeOAuth:
|
|
|
125
125
|
expires_at=int(time.time()) + int(expires_in),
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
-
def _do_refresh_request(self, refresh_token: str) -> httpx.Response:
|
|
129
|
-
"""Send token refresh request to OAuth server."""
|
|
130
|
-
payload = {
|
|
131
|
-
"grant_type": "refresh_token",
|
|
132
|
-
"client_id": CLIENT_ID,
|
|
133
|
-
"refresh_token": refresh_token,
|
|
134
|
-
}
|
|
135
|
-
with httpx.Client() as client:
|
|
136
|
-
return client.post(
|
|
137
|
-
TOKEN_URL,
|
|
138
|
-
json=payload,
|
|
139
|
-
headers={"Content-Type": "application/json"},
|
|
140
|
-
)
|
|
141
|
-
|
|
142
128
|
def refresh(self) -> ClaudeAuthState:
|
|
143
|
-
"""Refresh the access token using refresh token.
|
|
129
|
+
"""Refresh the access token using refresh token with file locking.
|
|
144
130
|
|
|
145
|
-
|
|
146
|
-
|
|
131
|
+
Uses file locking to prevent multiple instances from refreshing simultaneously.
|
|
132
|
+
If another instance has already refreshed, returns the updated state.
|
|
147
133
|
"""
|
|
148
|
-
state = self.token_manager.get_state()
|
|
149
|
-
if state is None:
|
|
150
|
-
raise ClaudeNotLoggedInError("Not logged in to Claude. Run 'klaude login claude' first.")
|
|
151
134
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
135
|
+
def do_refresh(current_state: ClaudeAuthState) -> ClaudeAuthState:
|
|
136
|
+
payload = {
|
|
137
|
+
"grant_type": "refresh_token",
|
|
138
|
+
"client_id": CLIENT_ID,
|
|
139
|
+
"refresh_token": current_state.refresh_token,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
with httpx.Client() as client:
|
|
143
|
+
response = client.post(
|
|
144
|
+
TOKEN_URL,
|
|
145
|
+
json=payload,
|
|
146
|
+
headers={"Content-Type": "application/json"},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if response.status_code != 200:
|
|
150
|
+
raise ClaudeAuthError(f"Token refresh failed: {response.text}")
|
|
151
|
+
|
|
152
|
+
tokens = response.json()
|
|
153
|
+
access_token = tokens["access_token"]
|
|
154
|
+
refresh_token = tokens.get("refresh_token", current_state.refresh_token)
|
|
155
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
156
|
+
|
|
157
|
+
return ClaudeAuthState(
|
|
158
|
+
access_token=access_token,
|
|
159
|
+
refresh_token=refresh_token,
|
|
160
|
+
expires_at=int(time.time()) + int(expires_in),
|
|
161
|
+
)
|
|
174
162
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
self.token_manager.save(new_state)
|
|
181
|
-
return new_state
|
|
163
|
+
try:
|
|
164
|
+
return self.token_manager.refresh_with_lock(do_refresh)
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
raise ClaudeNotLoggedInError(str(e)) from e
|
|
182
167
|
|
|
183
168
|
def ensure_valid_token(self) -> str:
|
|
184
169
|
"""Ensure we have a valid access token, refreshing if needed."""
|
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)
|
|
@@ -148,6 +148,8 @@ provider_list:
|
|
|
148
148
|
modalities:
|
|
149
149
|
- image
|
|
150
150
|
- text
|
|
151
|
+
image_config:
|
|
152
|
+
image_size: "4K"
|
|
151
153
|
cost: {input: 2, output: 12, cache_read: 0.2, image: 120}
|
|
152
154
|
|
|
153
155
|
- model_name: nano-banana
|
|
@@ -221,6 +223,8 @@ provider_list:
|
|
|
221
223
|
modalities:
|
|
222
224
|
- image
|
|
223
225
|
- text
|
|
226
|
+
image_config:
|
|
227
|
+
image_size: "4K"
|
|
224
228
|
cost: {input: 2, output: 12, cache_read: 0.2, image: 120}
|
|
225
229
|
|
|
226
230
|
- model_name: nano-banana
|
|
@@ -275,6 +279,19 @@ provider_list:
|
|
|
275
279
|
cost: {input: 4, output: 16, cache_read: 1, currency: CNY}
|
|
276
280
|
|
|
277
281
|
|
|
282
|
+
- provider_name: cerebras
|
|
283
|
+
protocol: openai
|
|
284
|
+
api_key: ${CEREBRAS_API_KEY}
|
|
285
|
+
base_url: https://api.cerebras.ai/v1
|
|
286
|
+
model_list:
|
|
287
|
+
|
|
288
|
+
- model_name: glm
|
|
289
|
+
model_id: zai-glm-4.7
|
|
290
|
+
context_limit: 131072
|
|
291
|
+
max_tokens: 12800
|
|
292
|
+
cost: {input: 2.25, output: 2.75}
|
|
293
|
+
|
|
294
|
+
|
|
278
295
|
- provider_name: claude-max
|
|
279
296
|
protocol: claude_oauth
|
|
280
297
|
disabled: true
|
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
|
|
@@ -132,7 +132,7 @@ def load_system_prompt(
|
|
|
132
132
|
|
|
133
133
|
# For codex_oauth protocol, use dynamic prompts from GitHub (no additions).
|
|
134
134
|
if protocol == llm_param.LLMClientProtocol.CODEX_OAUTH:
|
|
135
|
-
from klaude_code.llm.
|
|
135
|
+
from klaude_code.llm.openai_codex.prompt_sync import get_codex_instructions
|
|
136
136
|
|
|
137
137
|
return get_codex_instructions(model_name)
|
|
138
138
|
|
|
@@ -176,8 +176,6 @@ def load_agent_tools(
|
|
|
176
176
|
# Main agent tools
|
|
177
177
|
if "gpt-5" in model_name:
|
|
178
178
|
tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
|
|
179
|
-
elif "gemini-3" in model_name:
|
|
180
|
-
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
|
|
181
179
|
else:
|
|
182
180
|
tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
|
|
183
181
|
|
|
@@ -189,8 +187,7 @@ def load_agent_tools(
|
|
|
189
187
|
else:
|
|
190
188
|
tool_names.append(tools.IMAGE_GEN)
|
|
191
189
|
|
|
192
|
-
tool_names.
|
|
193
|
-
# tool_names.extend([tools.MEMORY])
|
|
190
|
+
tool_names.append(tools.MERMAID)
|
|
194
191
|
return get_tool_schemas(tool_names)
|
|
195
192
|
|
|
196
193
|
|
|
@@ -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
|
+
)
|