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.
Files changed (69) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/antigravity/oauth.py +33 -29
  3. klaude_code/auth/claude/oauth.py +34 -49
  4. klaude_code/cli/cost_cmd.py +4 -4
  5. klaude_code/cli/list_model.py +1 -2
  6. klaude_code/config/assets/builtin_config.yaml +17 -0
  7. klaude_code/const.py +4 -3
  8. klaude_code/core/agent_profile.py +2 -5
  9. klaude_code/core/bash_mode.py +276 -0
  10. klaude_code/core/executor.py +40 -7
  11. klaude_code/core/manager/llm_clients.py +1 -0
  12. klaude_code/core/manager/llm_clients_builder.py +2 -2
  13. klaude_code/core/memory.py +140 -0
  14. klaude_code/core/reminders.py +17 -89
  15. klaude_code/core/task.py +1 -1
  16. klaude_code/core/tool/file/read_tool.py +13 -2
  17. klaude_code/core/tool/shell/bash_tool.py +1 -1
  18. klaude_code/core/turn.py +10 -4
  19. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  20. klaude_code/llm/input_common.py +18 -0
  21. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  22. klaude_code/llm/{codex → openai_codex}/client.py +3 -3
  23. klaude_code/llm/openai_compatible/client.py +3 -1
  24. klaude_code/llm/openai_compatible/stream.py +19 -9
  25. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  26. klaude_code/llm/registry.py +3 -3
  27. klaude_code/llm/stream_parts.py +3 -1
  28. klaude_code/llm/usage.py +1 -1
  29. klaude_code/protocol/events.py +17 -1
  30. klaude_code/protocol/message.py +1 -0
  31. klaude_code/protocol/model.py +14 -1
  32. klaude_code/protocol/op.py +12 -0
  33. klaude_code/protocol/op_handler.py +5 -0
  34. klaude_code/session/session.py +22 -1
  35. klaude_code/tui/command/resume_cmd.py +1 -1
  36. klaude_code/tui/commands.py +15 -0
  37. klaude_code/tui/components/bash_syntax.py +4 -0
  38. klaude_code/tui/components/command_output.py +4 -5
  39. klaude_code/tui/components/developer.py +1 -3
  40. klaude_code/tui/components/diffs.py +3 -2
  41. klaude_code/tui/components/metadata.py +23 -26
  42. klaude_code/tui/components/rich/code_panel.py +31 -16
  43. klaude_code/tui/components/rich/markdown.py +44 -28
  44. klaude_code/tui/components/rich/status.py +2 -2
  45. klaude_code/tui/components/rich/theme.py +28 -16
  46. klaude_code/tui/components/tools.py +23 -0
  47. klaude_code/tui/components/user_input.py +49 -58
  48. klaude_code/tui/components/welcome.py +47 -2
  49. klaude_code/tui/display.py +15 -7
  50. klaude_code/tui/input/completers.py +8 -0
  51. klaude_code/tui/input/key_bindings.py +37 -1
  52. klaude_code/tui/input/prompt_toolkit.py +58 -31
  53. klaude_code/tui/machine.py +87 -49
  54. klaude_code/tui/renderer.py +148 -30
  55. klaude_code/tui/runner.py +22 -0
  56. klaude_code/tui/terminal/image.py +24 -3
  57. klaude_code/tui/terminal/notifier.py +11 -12
  58. klaude_code/tui/terminal/selector.py +1 -1
  59. klaude_code/ui/terminal/title.py +4 -2
  60. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
  61. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/RECORD +67 -66
  62. klaude_code/llm/bedrock/__init__.py +0 -3
  63. klaude_code/tui/components/assistant.py +0 -2
  64. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  65. /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
  66. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  67. /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
  68. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
  69. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
@@ -87,7 +87,7 @@ async def initialize_app_components(
87
87
  )
88
88
 
89
89
  if on_model_change is not None:
90
- on_model_change(llm_clients.main.model_name)
90
+ on_model_change(llm_clients.main_model_alias)
91
91
 
92
92
  executor_task = asyncio.create_task(executor.start())
93
93
 
@@ -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
- data = {
267
- "client_id": CLIENT_ID,
268
- "client_secret": CLIENT_SECRET,
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
- with httpx.Client() as client:
274
- response = client.post(TOKEN_URL, data=data, timeout=30)
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
- if response.status_code != 200:
277
- raise AntigravityTokenExpiredError(f"Token refresh failed: {response.text}")
275
+ with httpx.Client() as client:
276
+ response = client.post(TOKEN_URL, data=data, timeout=30)
278
277
 
279
- tokens = response.json()
280
- access_token = tokens["access_token"]
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
- # Calculate expiry time with 5 minute buffer
285
- expires_at = int(time.time()) + expires_in - 300
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
- new_state = AntigravityAuthState(
288
- access_token=access_token,
289
- refresh_token=refresh_token,
290
- expires_at=expires_at,
291
- project_id=state.project_id,
292
- email=state.email,
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
- self.token_manager.save(new_state)
296
- return new_state
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.
@@ -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
- Handles concurrent refresh race conditions by retrying with freshly loaded token
146
- if the first attempt fails with invalid_grant error.
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
- response = self._do_refresh_request(state.refresh_token)
153
-
154
- # Handle race condition: another process may have refreshed the token already
155
- if response.status_code != 200 and "invalid_grant" in response.text:
156
- # Reload token from file (another process may have updated it)
157
- self.token_manager.clear_cached_state()
158
- fresh_state = self.token_manager.load()
159
- if fresh_state and fresh_state.refresh_token != state.refresh_token:
160
- # Token was updated by another process
161
- if not fresh_state.is_expired():
162
- # New token is still valid, use it directly
163
- return fresh_state
164
- # New token expired, try refreshing with the new refresh_token
165
- response = self._do_refresh_request(fresh_state.refresh_token)
166
-
167
- if response.status_code != 200:
168
- raise ClaudeAuthError(f"Token refresh failed: {response.text}")
169
-
170
- tokens = response.json()
171
- access_token = tokens["access_token"]
172
- refresh_token = tokens.get("refresh_token", state.refresh_token)
173
- expires_in = tokens.get("expires_in", 3600)
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
- new_state = ClaudeAuthState(
176
- access_token=access_token,
177
- refresh_token=refresh_token,
178
- expires_at=int(time.time()) + int(expires_in),
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."""
@@ -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 = " └─ " if is_last_sub else " ├─ "
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 = " └─ " if is_last_model else " ├─ "
356
+ model_prefix = " ╰─ " if is_last_model else " ├─ "
357
357
  else:
358
- model_prefix = " │ └─ " if is_last_model else " │ ├─ "
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 = " └─ " if is_last_model else " ├─ "
364
+ model_prefix = " ╰─ " if is_last_model else " ├─ "
365
365
  add_stats_row(stats, prefix=model_prefix)
366
366
 
367
367
  if show_subtotal:
@@ -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 = " └─ " if is_last else " ├─ "
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 = 2 # Left margin (columns) for markdown rendering
160
- MARKDOWN_RIGHT_MARGIN = 2 # Right margin (columns) for markdown rendering
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.codex.prompt_sync import get_codex_instructions
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.extend([tools.MERMAID])
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
+ )