klaude-code 2.10.3__py3-none-any.whl → 2.10.4__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 (56) hide show
  1. klaude_code/auth/AGENTS.md +4 -24
  2. klaude_code/auth/__init__.py +1 -17
  3. klaude_code/cli/auth_cmd.py +3 -53
  4. klaude_code/cli/list_model.py +0 -50
  5. klaude_code/config/assets/builtin_config.yaml +0 -28
  6. klaude_code/config/config.py +5 -42
  7. klaude_code/const.py +5 -2
  8. klaude_code/core/agent_profile.py +2 -10
  9. klaude_code/core/backtrack/__init__.py +3 -0
  10. klaude_code/core/backtrack/manager.py +48 -0
  11. klaude_code/core/memory.py +25 -9
  12. klaude_code/core/task.py +53 -7
  13. klaude_code/core/tool/__init__.py +2 -0
  14. klaude_code/core/tool/backtrack/__init__.py +3 -0
  15. klaude_code/core/tool/backtrack/backtrack_tool.md +17 -0
  16. klaude_code/core/tool/backtrack/backtrack_tool.py +65 -0
  17. klaude_code/core/tool/context.py +5 -0
  18. klaude_code/core/turn.py +3 -0
  19. klaude_code/llm/input_common.py +70 -1
  20. klaude_code/llm/openai_compatible/input.py +5 -2
  21. klaude_code/llm/openrouter/input.py +5 -2
  22. klaude_code/llm/registry.py +0 -1
  23. klaude_code/protocol/events.py +10 -0
  24. klaude_code/protocol/llm_param.py +0 -1
  25. klaude_code/protocol/message.py +10 -1
  26. klaude_code/protocol/tools.py +1 -0
  27. klaude_code/session/session.py +111 -2
  28. klaude_code/session/store.py +2 -0
  29. klaude_code/skill/assets/executing-plans/SKILL.md +84 -0
  30. klaude_code/skill/assets/writing-plans/SKILL.md +116 -0
  31. klaude_code/tui/commands.py +15 -0
  32. klaude_code/tui/components/developer.py +1 -1
  33. klaude_code/tui/components/rich/status.py +7 -76
  34. klaude_code/tui/components/rich/theme.py +10 -0
  35. klaude_code/tui/components/tools.py +31 -18
  36. klaude_code/tui/display.py +4 -0
  37. klaude_code/tui/input/prompt_toolkit.py +15 -1
  38. klaude_code/tui/machine.py +26 -8
  39. klaude_code/tui/renderer.py +97 -0
  40. klaude_code/tui/runner.py +7 -2
  41. klaude_code/tui/terminal/image.py +28 -12
  42. klaude_code/ui/terminal/title.py +8 -3
  43. {klaude_code-2.10.3.dist-info → klaude_code-2.10.4.dist-info}/METADATA +1 -1
  44. {klaude_code-2.10.3.dist-info → klaude_code-2.10.4.dist-info}/RECORD +46 -49
  45. klaude_code/auth/antigravity/__init__.py +0 -20
  46. klaude_code/auth/antigravity/exceptions.py +0 -17
  47. klaude_code/auth/antigravity/oauth.py +0 -315
  48. klaude_code/auth/antigravity/pkce.py +0 -25
  49. klaude_code/auth/antigravity/token_manager.py +0 -27
  50. klaude_code/core/prompts/prompt-antigravity.md +0 -80
  51. klaude_code/llm/antigravity/__init__.py +0 -3
  52. klaude_code/llm/antigravity/client.py +0 -558
  53. klaude_code/llm/antigravity/input.py +0 -268
  54. klaude_code/skill/assets/create-plan/SKILL.md +0 -74
  55. {klaude_code-2.10.3.dist-info → klaude_code-2.10.4.dist-info}/WHEEL +0 -0
  56. {klaude_code-2.10.3.dist-info → klaude_code-2.10.4.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  # Adding a New OAuth Provider
2
2
 
3
- This document describes all the files and changes required to add a new OAuth-based authentication provider (like `antigravity`, `codex`, or `claude`).
3
+ This document describes all the files and changes required to add a new OAuth-based authentication provider (like `codex` or `claude`).
4
4
 
5
5
  ## Overview
6
6
 
@@ -25,7 +25,7 @@ Add the new protocol to the `LLMClientProtocol` enum:
25
25
  ```python
26
26
  class LLMClientProtocol(Enum):
27
27
  # ... existing protocols ...
28
- ANTIGRAVITY = "antigravity" # Add new protocol
28
+ NEW_PROVIDER = "new_provider" # Add new protocol
29
29
  ```
30
30
 
31
31
  ---
@@ -171,26 +171,8 @@ def is_api_key_missing(self) -> bool:
171
171
  return state is None
172
172
  ```
173
173
 
174
- #### `Config.resolve_model_location_prefer_available()`
175
-
176
- Add the protocol to the "no API key required" set:
177
-
178
- ```python
179
- if (
180
- provider.protocol
181
- not in {
182
- llm_param.LLMClientProtocol.CODEX_OAUTH,
183
- llm_param.LLMClientProtocol.CLAUDE_OAUTH,
184
- llm_param.LLMClientProtocol.<PROVIDER>, # Add here
185
- llm_param.LLMClientProtocol.BEDROCK,
186
- }
187
- and not api_key
188
- ):
189
- ```
190
-
191
- #### `Config.get_model_config()`
192
-
193
- Same change as above - add protocol to the set.
174
+ Note: `resolve_model_location_prefer_available()` and `get_model_config()` both use
175
+ `is_api_key_missing()` internally, so no additional changes are needed in those methods.
194
176
 
195
177
  ---
196
178
 
@@ -317,8 +299,6 @@ When adding a new OAuth provider, ensure you've completed:
317
299
  - [ ] Create `llm/<provider>/` module (input, client, __init__)
318
300
  - [ ] Register protocol in `llm/registry.py`
319
301
  - [ ] Update `config.py` - `is_api_key_missing()` method
320
- - [ ] Update `config.py` - `resolve_model_location_prefer_available()` method
321
- - [ ] Update `config.py` - `get_model_config()` method
322
302
  - [ ] Update `auth_cmd.py` - provider selection, login, logout
323
303
  - [ ] Update `list_model.py` - status display function and panel
324
304
  - [ ] Add provider/models to `builtin_config.yaml`
@@ -1,17 +1,8 @@
1
1
  """Authentication module.
2
2
 
3
- Includes Codex and Antigravity OAuth helpers.
3
+ Includes OAuth helpers for various providers.
4
4
  """
5
5
 
6
- from klaude_code.auth.antigravity import (
7
- AntigravityAuthError,
8
- AntigravityAuthState,
9
- AntigravityNotLoggedInError,
10
- AntigravityOAuth,
11
- AntigravityOAuthError,
12
- AntigravityTokenExpiredError,
13
- AntigravityTokenManager,
14
- )
15
6
  from klaude_code.auth.codex import (
16
7
  CodexAuthError,
17
8
  CodexAuthState,
@@ -29,13 +20,6 @@ from klaude_code.auth.env import (
29
20
  )
30
21
 
31
22
  __all__ = [
32
- "AntigravityAuthError",
33
- "AntigravityAuthState",
34
- "AntigravityNotLoggedInError",
35
- "AntigravityOAuth",
36
- "AntigravityOAuthError",
37
- "AntigravityTokenExpiredError",
38
- "AntigravityTokenManager",
39
23
  "CodexAuthError",
40
24
  "CodexAuthState",
41
25
  "CodexNotLoggedInError",
@@ -24,11 +24,6 @@ def _select_provider() -> str | None:
24
24
  value="codex",
25
25
  search_text="codex",
26
26
  ),
27
- SelectItem(
28
- title=[("", "Google Antigravity "), ("ansibrightblack", "[OAuth]\n")],
29
- value="antigravity",
30
- search_text="antigravity",
31
- ),
32
27
  ]
33
28
  # Add API key options
34
29
  for key_info in SUPPORTED_API_KEYS:
@@ -76,7 +71,7 @@ def _build_provider_help() -> str:
76
71
  from klaude_code.config.builtin_config import SUPPORTED_API_KEYS
77
72
 
78
73
  # Use first word of name for brevity (e.g., "google" instead of "google gemini")
79
- names = ["codex", "claude", "antigravity"] + [k.name.split()[0].lower() for k in SUPPORTED_API_KEYS]
74
+ names = ["codex", "claude"] + [k.name.split()[0].lower() for k in SUPPORTED_API_KEYS]
80
75
  return f"Provider name ({', '.join(names)})"
81
76
 
82
77
 
@@ -154,39 +149,6 @@ def login_command(
154
149
  except Exception as e:
155
150
  log((f"Login failed: {e}", "red"))
156
151
  raise typer.Exit(1) from None
157
- case "antigravity":
158
- from klaude_code.auth.antigravity.oauth import AntigravityOAuth
159
- from klaude_code.auth.antigravity.token_manager import AntigravityTokenManager
160
-
161
- token_manager = AntigravityTokenManager()
162
-
163
- if token_manager.is_logged_in():
164
- state = token_manager.get_state()
165
- if state and not state.is_expired():
166
- log(("You are already logged in to Antigravity.", "green"))
167
- if state.email:
168
- log(f" Email: {state.email}")
169
- log(f" Project ID: {state.project_id}")
170
- expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
171
- log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
172
- if not typer.confirm("Do you want to re-login?"):
173
- return
174
-
175
- log("Starting Antigravity OAuth login flow...")
176
- log("A browser window will open for authentication.")
177
-
178
- try:
179
- oauth = AntigravityOAuth(token_manager)
180
- state = oauth.login()
181
- log(("Login successful!", "green"))
182
- if state.email:
183
- log(f" Email: {state.email}")
184
- log(f" Project ID: {state.project_id}")
185
- expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
186
- log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
187
- except Exception as e:
188
- log((f"Login failed: {e}", "red"))
189
- raise typer.Exit(1) from None
190
152
  case _:
191
153
  from klaude_code.config.builtin_config import SUPPORTED_API_KEYS
192
154
 
@@ -212,7 +174,7 @@ def login_command(
212
174
 
213
175
 
214
176
  def logout_command(
215
- provider: str = typer.Argument("codex", help="Provider to logout (codex|claude|antigravity)"),
177
+ provider: str = typer.Argument("codex", help="Provider to logout (codex|claude)"),
216
178
  ) -> None:
217
179
  """Logout from a provider."""
218
180
  match provider.lower():
@@ -240,20 +202,8 @@ def logout_command(
240
202
  if typer.confirm("Are you sure you want to logout from Claude?"):
241
203
  token_manager.delete()
242
204
  log(("Logged out from Claude.", "green"))
243
- case "antigravity":
244
- from klaude_code.auth.antigravity.token_manager import AntigravityTokenManager
245
-
246
- token_manager = AntigravityTokenManager()
247
-
248
- if not token_manager.is_logged_in():
249
- log("You are not logged in to Antigravity.")
250
- return
251
-
252
- if typer.confirm("Are you sure you want to logout from Antigravity?"):
253
- token_manager.delete()
254
- log(("Logged out from Antigravity.", "green"))
255
205
  case _:
256
- log((f"Error: Unknown provider '{provider}'. Supported: codex, claude, antigravity", "red"))
206
+ log((f"Error: Unknown provider '{provider}'. Supported: codex, claude", "red"))
257
207
  raise typer.Exit(1)
258
208
 
259
209
 
@@ -121,53 +121,6 @@ def _get_claude_status_rows() -> list[tuple[Text, Text]]:
121
121
  return rows
122
122
 
123
123
 
124
- def _get_antigravity_status_rows() -> list[tuple[Text, Text]]:
125
- """Get Antigravity OAuth login status as (label, value) tuples for table display."""
126
- from klaude_code.auth.antigravity.token_manager import AntigravityTokenManager
127
-
128
- rows: list[tuple[Text, Text]] = []
129
- token_manager = AntigravityTokenManager()
130
- state = token_manager.get_state()
131
-
132
- if state is None:
133
- rows.append(
134
- (
135
- Text("Status", style=ThemeKey.CONFIG_PARAM_LABEL),
136
- Text.assemble(
137
- ("Not logged in", ThemeKey.CONFIG_STATUS_ERROR),
138
- (" (run 'klaude login antigravity' to authenticate)", "dim"),
139
- ),
140
- )
141
- )
142
- elif state.is_expired():
143
- rows.append(
144
- (
145
- Text("Status", style=ThemeKey.CONFIG_PARAM_LABEL),
146
- Text.assemble(
147
- ("Token expired", ThemeKey.CONFIG_STATUS_ERROR),
148
- (" (will refresh automatically on use; run 'klaude login antigravity' if refresh fails)", "dim"),
149
- ),
150
- )
151
- )
152
- else:
153
- expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
154
- email_info = f", email: {state.email}" if state.email else ""
155
- rows.append(
156
- (
157
- Text("Status", style=ThemeKey.CONFIG_PARAM_LABEL),
158
- Text.assemble(
159
- ("Logged in", ThemeKey.CONFIG_STATUS_OK),
160
- (
161
- f" (project: {state.project_id}{email_info}, expires: {expires_dt.strftime('%Y-%m-%d %H:%M UTC')})",
162
- "dim",
163
- ),
164
- ),
165
- )
166
- )
167
-
168
- return rows
169
-
170
-
171
124
  def mask_api_key(api_key: str | None) -> str:
172
125
  """Mask API key to show only first 6 and last 6 characters with *** in between"""
173
126
  if not api_key:
@@ -286,9 +239,6 @@ def _build_provider_info_panel(provider: ProviderConfig, available: bool, *, dis
286
239
  if provider.protocol == LLMClientProtocol.CLAUDE_OAUTH:
287
240
  for label, value in _get_claude_status_rows():
288
241
  info_table.add_row(label, value)
289
- if provider.protocol == LLMClientProtocol.ANTIGRAVITY:
290
- for label, value in _get_antigravity_status_rows():
291
- info_table.add_row(label, value)
292
242
 
293
243
  return Quote(
294
244
  Group(title, info_table),
@@ -341,32 +341,4 @@ provider_list:
341
341
  cost: {input: 1.75, output: 14, cache_read: 0.17}
342
342
 
343
343
 
344
- - provider_name: antigravity
345
- protocol: antigravity
346
- model_list:
347
- - model_name: opus
348
- model_id: claude-opus-4-5-thinking
349
- context_limit: 200000
350
- max_tokens: 64000
351
-
352
- - model_name: sonnet
353
- model_id: claude-sonnet-4-5
354
- context_limit: 200000
355
- max_tokens: 64000
356
-
357
- - model_name: gemini-pro-high
358
- model_id: gemini-3-pro-high
359
- context_limit: 1048576
360
- max_tokens: 65535
361
- thinking:
362
- reasoning_effort: high
363
-
364
- - model_name: gemini-flash
365
- model_id: gemini-3-flash
366
- context_limit: 1048576
367
- max_tokens: 65535
368
- thinking:
369
- reasoning_effort: medium
370
-
371
-
372
344
  compact_model: gemini-flash
@@ -101,15 +101,6 @@ class ProviderConfig(llm_param.LLMConfigProviderParameter):
101
101
  # Consider available if logged in. Token refresh happens on-demand.
102
102
  return state is None
103
103
 
104
- if self.protocol == LLMClientProtocol.ANTIGRAVITY:
105
- # Antigravity uses OAuth authentication, not API key
106
- from klaude_code.auth.antigravity.token_manager import AntigravityTokenManager
107
-
108
- token_manager = AntigravityTokenManager()
109
- state = token_manager.get_state()
110
- # Consider available if logged in. Token refresh happens on-demand.
111
- return state is None
112
-
113
104
  if self.protocol == LLMClientProtocol.BEDROCK:
114
105
  # Bedrock uses AWS credentials, not API key. Region is always required.
115
106
  _, resolved_profile = parse_env_var_syntax(self.aws_profile)
@@ -275,8 +266,7 @@ class Config(BaseModel):
275
266
  def resolve_model_location_prefer_available(self, model_selector: str) -> tuple[str, str] | None:
276
267
  """Resolve a selector to (model_name, provider_name), preferring usable providers.
277
268
 
278
- This uses the same availability logic as :meth:`get_model_config` (API-key
279
- presence for non-OAuth protocols).
269
+ This uses the same availability logic as :meth:`get_model_config`.
280
270
  """
281
271
 
282
272
  requested_model, requested_provider = self._split_model_selector(model_selector)
@@ -285,20 +275,7 @@ class Config(BaseModel):
285
275
  if requested_provider is not None and provider.provider_name.casefold() != requested_provider.casefold():
286
276
  continue
287
277
 
288
- if provider.disabled:
289
- continue
290
-
291
- api_key = provider.get_resolved_api_key()
292
- if (
293
- provider.protocol
294
- not in {
295
- llm_param.LLMClientProtocol.CODEX_OAUTH,
296
- llm_param.LLMClientProtocol.CLAUDE_OAUTH,
297
- llm_param.LLMClientProtocol.ANTIGRAVITY,
298
- llm_param.LLMClientProtocol.BEDROCK,
299
- }
300
- and not api_key
301
- ):
278
+ if provider.disabled or provider.is_api_key_missing():
302
279
  continue
303
280
 
304
281
  for model in provider.model_list:
@@ -322,24 +299,10 @@ class Config(BaseModel):
322
299
  raise ValueError(f"Provider '{provider.provider_name}' is disabled for: {model_name}")
323
300
  continue
324
301
 
325
- # Resolve ${ENV_VAR} syntax for api_key
326
- api_key = provider.get_resolved_api_key()
327
-
328
- # Some protocols do not use API keys for authentication.
329
- if (
330
- provider.protocol
331
- not in {
332
- llm_param.LLMClientProtocol.CODEX_OAUTH,
333
- llm_param.LLMClientProtocol.CLAUDE_OAUTH,
334
- llm_param.LLMClientProtocol.ANTIGRAVITY,
335
- llm_param.LLMClientProtocol.BEDROCK,
336
- }
337
- and not api_key
338
- ):
339
- # When provider is explicitly requested, fail fast with a clearer error.
302
+ if provider.is_api_key_missing():
340
303
  if requested_provider is not None:
341
304
  raise ValueError(
342
- f"Provider '{provider.provider_name}' is not available (missing API key) for: {model_name}"
305
+ f"Provider '{provider.provider_name}' is not available (missing credentials) for: {model_name}"
343
306
  )
344
307
  continue
345
308
 
@@ -355,7 +318,7 @@ class Config(BaseModel):
355
318
  break
356
319
 
357
320
  provider_dump = provider.model_dump(exclude={"model_list", "disabled"})
358
- provider_dump["api_key"] = api_key
321
+ provider_dump["api_key"] = provider.get_resolved_api_key()
359
322
  return llm_param.LLMConfigParameter(
360
323
  **provider_dump,
361
324
  **model.model_dump(exclude={"model_name"}),
klaude_code/const.py CHANGED
@@ -141,7 +141,9 @@ MIN_HIDDEN_LINES_FOR_INDICATOR = 5 # Minimum hidden lines before showing trunca
141
141
  SUB_AGENT_RESULT_MAX_LINES = 10 # Maximum lines for sub-agent result display
142
142
  TRUNCATE_HEAD_MAX_LINES = 2 # Maximum lines for sub-agent error display
143
143
  BASH_OUTPUT_PANEL_THRESHOLD = 10 # Bash output line threshold for CodePanel display
144
- BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES = 4 # Max lines shown for heredoc / multiline string tokens in bash tool calls
144
+ BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES = (
145
+ 20 # Max lines shown for heredoc / multiline string tokens in bash tool calls
146
+ )
145
147
  URL_TRUNCATE_MAX_LENGTH = 400 # Maximum length for URL truncation in display
146
148
  QUERY_DISPLAY_TRUNCATE_LENGTH = 80 # Maximum length for search query display
147
149
  NOTIFY_COMPACT_LIMIT = 160 # Maximum length for notification body text
@@ -169,7 +171,7 @@ STATUS_HINT_TEXT = " (esc to interrupt)" # Status hint text shown after spinner
169
171
  # Spinner status texts
170
172
  STATUS_WAITING_TEXT = "Loading …"
171
173
  STATUS_THINKING_TEXT = "Thinking …"
172
- STATUS_COMPOSING_TEXT = "Composing"
174
+ STATUS_COMPOSING_TEXT = "Typing"
173
175
  STATUS_COMPACTING_TEXT = "Compacting"
174
176
  STATUS_RUNNING_TEXT = "Running …"
175
177
 
@@ -177,6 +179,7 @@ STATUS_RUNNING_TEXT = "Running …"
177
179
  STATUS_DEFAULT_TEXT = STATUS_WAITING_TEXT
178
180
  SIGINT_DOUBLE_PRESS_EXIT_TEXT = "Press ctrl+c again to exit" # Toast shown on first Ctrl+C during task waits
179
181
  SPINNER_BREATH_PERIOD_SECONDS: float = 2.0 # Spinner breathing animation period (seconds)
182
+ STATUS_SHIMMER_ENABLED = True # Enable shimmer effect on status text
180
183
  STATUS_SHIMMER_PADDING = 10 # Horizontal padding for shimmer band position
181
184
  STATUS_SHIMMER_BAND_HALF_WIDTH = 5.0 # Half-width of shimmer band in characters
182
185
  STATUS_SHIMMER_ALPHA_SCALE = 0.7 # Scale factor for shimmer intensity
@@ -52,10 +52,6 @@ COMMAND_DESCRIPTIONS: dict[str, str] = {
52
52
  }
53
53
 
54
54
 
55
- # Prompt for antigravity protocol - used exactly as-is without any additions.
56
- ANTIGRAVITY_PROMPT_PATH = "prompts/prompt-antigravity.md"
57
-
58
-
59
55
  STRUCTURED_OUTPUT_PROMPT_FOR_SUB_AGENT = """\
60
56
 
61
57
  # Structured Output
@@ -130,10 +126,6 @@ def load_system_prompt(
130
126
  ) -> str:
131
127
  """Get system prompt content for the given model and sub-agent type."""
132
128
 
133
- # For antigravity protocol, use exact prompt without any additions.
134
- if protocol == llm_param.LLMClientProtocol.ANTIGRAVITY:
135
- return _load_prompt_by_path(ANTIGRAVITY_PROMPT_PATH)
136
-
137
129
  if sub_agent_type is not None:
138
130
  profile = get_sub_agent_profile(sub_agent_type)
139
131
  base_prompt = _load_prompt_by_path(profile.prompt_file)
@@ -169,9 +161,9 @@ def load_agent_tools(
169
161
 
170
162
  # Main agent tools
171
163
  if "gpt-5" in model_name:
172
- tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
164
+ tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN, tools.BACKTRACK]
173
165
  else:
174
- tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
166
+ tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE, tools.BACKTRACK]
175
167
 
176
168
  tool_names.append(tools.TASK)
177
169
  if config is not None:
@@ -0,0 +1,3 @@
1
+ from klaude_code.core.backtrack.manager import BacktrackManager, BacktrackRequest
2
+
3
+ __all__ = ["BacktrackManager", "BacktrackRequest"]
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class BacktrackRequest:
6
+ checkpoint_id: int
7
+ note: str
8
+ rationale: str
9
+
10
+
11
+ class BacktrackManager:
12
+ """Manage backtrack requests and checkpoint metadata for a task run."""
13
+
14
+ def __init__(self) -> None:
15
+ self._pending: BacktrackRequest | None = None
16
+ self._n_checkpoints: int = 0
17
+ self._checkpoint_user_messages: dict[int, str] = {}
18
+
19
+ def set_n_checkpoints(self, n: int) -> None:
20
+ self._n_checkpoints = n
21
+
22
+ @property
23
+ def n_checkpoints(self) -> int:
24
+ return self._n_checkpoints
25
+
26
+ def sync_checkpoints(self, checkpoints: dict[int, str]) -> None:
27
+ self._checkpoint_user_messages = dict(checkpoints)
28
+
29
+ def register_checkpoint(self, checkpoint_id: int, user_message: str) -> None:
30
+ self._checkpoint_user_messages[checkpoint_id] = user_message
31
+
32
+ def get_checkpoint_user_message(self, checkpoint_id: int) -> str | None:
33
+ return self._checkpoint_user_messages.get(checkpoint_id)
34
+
35
+ def send_backtrack(self, checkpoint_id: int, note: str, rationale: str) -> str:
36
+ if self._pending is not None:
37
+ raise ValueError("Only one backtrack can be pending at a time")
38
+ if checkpoint_id < 0 or checkpoint_id >= self._n_checkpoints:
39
+ raise ValueError(f"Invalid checkpoint {checkpoint_id}, available: 0-{self._n_checkpoints - 1}")
40
+ if checkpoint_id not in self._checkpoint_user_messages:
41
+ raise ValueError("Checkpoint is no longer available")
42
+ self._pending = BacktrackRequest(checkpoint_id=checkpoint_id, note=note, rationale=rationale)
43
+ return "Backtrack scheduled"
44
+
45
+ def fetch_pending(self) -> BacktrackRequest | None:
46
+ pending = self._pending
47
+ self._pending = None
48
+ return pending
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from pydantic import BaseModel
11
11
 
12
- MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"]
12
+ MEMORY_FILE_NAMES = ["AGENTS.md", "CLAUDE.md", "AGENT.md"]
13
13
 
14
14
 
15
15
  class Memory(BaseModel):
@@ -36,12 +36,20 @@ def get_memory_paths(*, work_dir: Path) -> list[tuple[Path, str]]:
36
36
 
37
37
 
38
38
  def get_existing_memory_files(*, work_dir: Path) -> dict[str, list[str]]:
39
- """Return existing memory file paths grouped by location (user/project)."""
39
+ """Return existing memory file paths grouped by location (user/project).
40
+
41
+ Only one memory file per directory is loaded, with priority: AGENTS.md > CLAUDE.md > AGENT.md
42
+ """
40
43
  result: dict[str, list[str]] = {"user": [], "project": []}
41
44
  work_dir = work_dir.resolve()
45
+ seen_dirs: set[Path] = set()
42
46
 
43
47
  for memory_path, _instruction in get_memory_paths(work_dir=work_dir):
48
+ parent = memory_path.parent.resolve()
49
+ if parent in seen_dirs:
50
+ continue
44
51
  if memory_path.exists() and memory_path.is_file():
52
+ seen_dirs.add(parent)
45
53
  path_str = str(memory_path)
46
54
  resolved = memory_path.resolve()
47
55
  try:
@@ -87,6 +95,8 @@ def discover_memory_files_near_paths(
87
95
  ) -> list[Memory]:
88
96
  """Discover and load CLAUDE.md/AGENTS.md from directories containing accessed files.
89
97
 
98
+ Only one memory file per directory is loaded, with priority: AGENTS.md > CLAUDE.md > AGENT.md
99
+
90
100
  Args:
91
101
  paths: List of file paths that have been accessed.
92
102
  is_memory_loaded: Callback to check if a memory file is already loaded.
@@ -97,7 +107,7 @@ def discover_memory_files_near_paths(
97
107
  """
98
108
  memories: list[Memory] = []
99
109
  work_dir = work_dir.resolve()
100
- seen_memory_files: set[str] = set()
110
+ seen_dirs: set[Path] = set()
101
111
 
102
112
  for p_str in paths:
103
113
  p = Path(p_str)
@@ -117,24 +127,30 @@ def discover_memory_files_near_paths(
117
127
  current_dir = work_dir
118
128
  for part in rel_parts:
119
129
  current_dir = current_dir / part
130
+ if current_dir in seen_dirs:
131
+ continue
132
+ # Check if any memory file in this directory was already loaded
133
+ dir_already_loaded = any(is_memory_loaded(str(current_dir / fname)) for fname in MEMORY_FILE_NAMES)
134
+ if dir_already_loaded:
135
+ seen_dirs.add(current_dir)
136
+ continue
137
+ # Load first existing memory file in priority order
120
138
  for fname in MEMORY_FILE_NAMES:
121
139
  mem_path = current_dir / fname
122
- mem_path_str = str(mem_path)
123
- if mem_path_str in seen_memory_files or is_memory_loaded(mem_path_str):
124
- continue
125
140
  if mem_path.exists() and mem_path.is_file():
126
141
  try:
127
142
  text = mem_path.read_text(encoding="utf-8", errors="replace")
128
143
  except (PermissionError, UnicodeDecodeError, OSError):
129
144
  continue
130
- mark_memory_loaded(mem_path_str)
131
- seen_memory_files.add(mem_path_str)
145
+ mark_memory_loaded(str(mem_path))
146
+ seen_dirs.add(current_dir)
132
147
  memories.append(
133
148
  Memory(
134
- path=mem_path_str,
149
+ path=str(mem_path),
135
150
  instruction="project instructions, discovered near last accessed path",
136
151
  content=text,
137
152
  )
138
153
  )
154
+ break
139
155
 
140
156
  return memories
klaude_code/core/task.py CHANGED
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
 
8
8
  from klaude_code.const import INITIAL_RETRY_DELAY_S, MAX_FAILED_TURN_RETRIES, MAX_RETRY_DELAY_S
9
9
  from klaude_code.core.agent_profile import AgentProfile, Reminder
10
+ from klaude_code.core.backtrack import BacktrackManager
10
11
  from klaude_code.core.compaction import (
11
12
  CompactionReason,
12
13
  is_context_overflow,
@@ -178,6 +179,7 @@ class TaskExecutor:
178
179
  self._current_turn: TurnExecutor | None = None
179
180
  self._started_at: float = 0.0
180
181
  self._metadata_accumulator: MetadataAccumulator | None = None
182
+ self._backtrack_manager: BacktrackManager | None = None
181
183
 
182
184
  def get_partial_metadata(self) -> model.TaskMetadata | None:
183
185
  """Get the currently accumulated metadata without finalizing.
@@ -221,6 +223,11 @@ class TaskExecutor:
221
223
  session_ctx = ctx.session_ctx
222
224
  self._started_at = time.perf_counter()
223
225
 
226
+ if ctx.sub_agent_state is None:
227
+ self._backtrack_manager = BacktrackManager()
228
+ self._backtrack_manager.set_n_checkpoints(ctx.session.n_checkpoints)
229
+ self._backtrack_manager.sync_checkpoints(ctx.session.get_checkpoint_user_messages())
230
+
224
231
  yield events.TaskStartEvent(
225
232
  session_id=session_ctx.session_id,
226
233
  sub_agent_state=ctx.sub_agent_state,
@@ -262,6 +269,9 @@ class TaskExecutor:
262
269
  log_debug("[Compact] result", str(result.to_entry()), debug_type=DebugType.RESPONSE)
263
270
 
264
271
  session_ctx.append_history([result.to_entry()])
272
+ if self._backtrack_manager is not None:
273
+ self._backtrack_manager.set_n_checkpoints(ctx.session.n_checkpoints)
274
+ self._backtrack_manager.sync_checkpoints(ctx.session.get_checkpoint_user_messages())
265
275
  yield events.CompactionEndEvent(
266
276
  session_id=session_ctx.session_id,
267
277
  reason=CompactionReason.THRESHOLD.value,
@@ -298,6 +308,12 @@ class TaskExecutor:
298
308
  will_retry=False,
299
309
  )
300
310
 
311
+ if self._backtrack_manager is not None:
312
+ checkpoint_id = ctx.session.create_checkpoint()
313
+ self._backtrack_manager.set_n_checkpoints(ctx.session.n_checkpoints)
314
+ user_msg = ctx.session.get_user_message_before_checkpoint(checkpoint_id) or ""
315
+ self._backtrack_manager.register_checkpoint(checkpoint_id, user_msg)
316
+
301
317
  turn_context = TurnExecutionContext(
302
318
  session_ctx=session_ctx,
303
319
  llm_client=profile.llm_client,
@@ -305,6 +321,7 @@ class TaskExecutor:
305
321
  tools=profile.tools,
306
322
  tool_registry=ctx.tool_registry,
307
323
  sub_agent_state=ctx.sub_agent_state,
324
+ backtrack_manager=self._backtrack_manager,
308
325
  )
309
326
 
310
327
  turn: TurnExecutor | None = None
@@ -354,6 +371,9 @@ class TaskExecutor:
354
371
  "[Compact:Overflow] result", str(result.to_entry()), debug_type=DebugType.RESPONSE
355
372
  )
356
373
  session_ctx.append_history([result.to_entry()])
374
+ if self._backtrack_manager is not None:
375
+ self._backtrack_manager.set_n_checkpoints(ctx.session.n_checkpoints)
376
+ self._backtrack_manager.sync_checkpoints(ctx.session.get_checkpoint_user_messages())
357
377
  yield events.CompactionEndEvent(
358
378
  session_id=session_ctx.session_id,
359
379
  reason=CompactionReason.OVERFLOW.value,
@@ -414,14 +434,40 @@ class TaskExecutor:
414
434
  yield events.ErrorEvent(error_message=final_error, can_retry=False, session_id=session_ctx.session_id)
415
435
  return
416
436
 
417
- if turn is None or turn.task_finished:
418
- # Empty result should retry instead of finishing
419
- if turn is not None and not turn.task_result.strip():
420
- if ctx.sub_agent_state is not None:
421
- error_msg = "Sub-agent returned empty result, retrying…"
437
+ if self._backtrack_manager is not None:
438
+ pending = self._backtrack_manager.fetch_pending()
439
+ if pending is not None:
440
+ try:
441
+ entry = ctx.session.revert_to_checkpoint(pending.checkpoint_id, pending.note, pending.rationale)
442
+ except ValueError as exc:
443
+ yield events.ErrorEvent(
444
+ error_message=str(exc),
445
+ can_retry=False,
446
+ session_id=session_ctx.session_id,
447
+ )
422
448
  else:
423
- error_msg = "Agent returned empty result, retrying…"
424
- yield events.ErrorEvent(error_message=error_msg, can_retry=True, session_id=session_ctx.session_id)
449
+ messages_discarded = entry.reverted_from_index - len(ctx.session.conversation_history)
450
+ session_ctx.append_history([entry])
451
+ self._backtrack_manager.set_n_checkpoints(ctx.session.n_checkpoints)
452
+ self._backtrack_manager.sync_checkpoints(ctx.session.get_checkpoint_user_messages())
453
+ yield events.BacktrackEvent(
454
+ session_id=session_ctx.session_id,
455
+ checkpoint_id=pending.checkpoint_id,
456
+ note=pending.note,
457
+ rationale=pending.rationale,
458
+ original_user_message=entry.original_user_message,
459
+ messages_discarded=messages_discarded,
460
+ )
461
+ continue
462
+
463
+ if turn is None or turn.task_finished:
464
+ # Empty result should retry only for sub-agents
465
+ if turn is not None and not turn.task_result.strip() and ctx.sub_agent_state is not None:
466
+ yield events.ErrorEvent(
467
+ error_message="Sub-agent returned empty result, retrying…",
468
+ can_retry=True,
469
+ session_id=session_ctx.session_id,
470
+ )
425
471
  continue
426
472
  break
427
473