klaude-code 1.2.18__py3-none-any.whl → 1.2.20__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 (74) hide show
  1. klaude_code/cli/main.py +42 -22
  2. klaude_code/cli/runtime.py +46 -2
  3. klaude_code/{version.py → cli/self_update.py} +110 -2
  4. klaude_code/command/__init__.py +1 -3
  5. klaude_code/command/clear_cmd.py +5 -4
  6. klaude_code/command/command_abc.py +5 -40
  7. klaude_code/command/debug_cmd.py +2 -2
  8. klaude_code/command/diff_cmd.py +2 -1
  9. klaude_code/command/export_cmd.py +14 -49
  10. klaude_code/command/export_online_cmd.py +10 -4
  11. klaude_code/command/help_cmd.py +2 -1
  12. klaude_code/command/model_cmd.py +7 -5
  13. klaude_code/command/prompt-jj-workspace.md +18 -0
  14. klaude_code/command/prompt_command.py +16 -9
  15. klaude_code/command/refresh_cmd.py +3 -2
  16. klaude_code/command/registry.py +98 -28
  17. klaude_code/command/release_notes_cmd.py +2 -1
  18. klaude_code/command/status_cmd.py +2 -1
  19. klaude_code/command/terminal_setup_cmd.py +2 -1
  20. klaude_code/command/thinking_cmd.py +6 -4
  21. klaude_code/core/executor.py +187 -180
  22. klaude_code/core/manager/sub_agent_manager.py +3 -0
  23. klaude_code/core/prompt.py +4 -1
  24. klaude_code/core/prompts/prompt-sub-agent-explore.md +14 -2
  25. klaude_code/core/prompts/prompt-sub-agent-web.md +3 -3
  26. klaude_code/core/reminders.py +70 -26
  27. klaude_code/core/task.py +13 -12
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/apply_patch_tool.py +3 -1
  30. klaude_code/core/tool/file/edit_tool.py +7 -5
  31. klaude_code/core/tool/file/multi_edit_tool.py +7 -5
  32. klaude_code/core/tool/file/read_tool.md +1 -1
  33. klaude_code/core/tool/file/read_tool.py +8 -4
  34. klaude_code/core/tool/file/write_tool.py +8 -6
  35. klaude_code/core/tool/memory/skill_loader.py +12 -10
  36. klaude_code/core/tool/shell/bash_tool.py +89 -17
  37. klaude_code/core/tool/sub_agent_tool.py +5 -1
  38. klaude_code/core/tool/tool_abc.py +18 -0
  39. klaude_code/core/tool/tool_context.py +6 -6
  40. klaude_code/core/tool/tool_registry.py +1 -1
  41. klaude_code/core/tool/tool_runner.py +7 -7
  42. klaude_code/core/tool/web/web_fetch_tool.py +77 -22
  43. klaude_code/core/tool/web/web_search_tool.py +5 -1
  44. klaude_code/llm/anthropic/client.py +25 -9
  45. klaude_code/llm/openai_compatible/client.py +5 -2
  46. klaude_code/llm/openrouter/client.py +7 -3
  47. klaude_code/llm/responses/client.py +6 -1
  48. klaude_code/protocol/model.py +8 -1
  49. klaude_code/protocol/op.py +47 -0
  50. klaude_code/protocol/op_handler.py +25 -1
  51. klaude_code/protocol/sub_agent/web.py +1 -1
  52. klaude_code/session/codec.py +71 -0
  53. klaude_code/session/export.py +21 -11
  54. klaude_code/session/session.py +186 -322
  55. klaude_code/session/store.py +215 -0
  56. klaude_code/session/templates/export_session.html +48 -47
  57. klaude_code/ui/modes/repl/completers.py +211 -71
  58. klaude_code/ui/modes/repl/event_handler.py +7 -23
  59. klaude_code/ui/modes/repl/input_prompt_toolkit.py +5 -7
  60. klaude_code/ui/modes/repl/renderer.py +2 -2
  61. klaude_code/ui/renderers/common.py +54 -0
  62. klaude_code/ui/renderers/developer.py +2 -3
  63. klaude_code/ui/renderers/errors.py +1 -1
  64. klaude_code/ui/renderers/metadata.py +10 -1
  65. klaude_code/ui/renderers/tools.py +3 -4
  66. klaude_code/ui/rich/__init__.py +10 -1
  67. klaude_code/ui/rich/cjk_wrap.py +228 -0
  68. klaude_code/ui/rich/status.py +0 -1
  69. klaude_code/ui/utils/common.py +0 -18
  70. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/METADATA +18 -2
  71. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/RECORD +73 -70
  72. klaude_code/ui/utils/debouncer.py +0 -42
  73. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/WHEEL +0 -0
  74. {klaude_code-1.2.18.dist-info → klaude_code-1.2.20.dist-info}/entry_points.txt +0 -0
@@ -9,6 +9,8 @@ from klaude_code.protocol import model
9
9
  from klaude_code.protocol.sub_agent import SubAgentResult
10
10
  from klaude_code.session.session import Session
11
11
 
12
+ type FileTracker = MutableMapping[str, model.FileStatus]
13
+
12
14
 
13
15
  @dataclass
14
16
  class TodoContext:
@@ -44,15 +46,13 @@ class ToolContextToken:
44
46
  finishes running.
45
47
  """
46
48
 
47
- file_tracker_token: Token[MutableMapping[str, float] | None] | None
49
+ file_tracker_token: Token[FileTracker | None] | None
48
50
  todo_token: Token[TodoContext | None] | None
49
51
 
50
52
 
51
53
  # Holds the current file tracker mapping for tool execution context.
52
54
  # Set by Agent/Reminder right before invoking a tool.
53
- current_file_tracker_var: ContextVar[MutableMapping[str, float] | None] = ContextVar(
54
- "current_file_tracker", default=None
55
- )
55
+ current_file_tracker_var: ContextVar[FileTracker | None] = ContextVar("current_file_tracker", default=None)
56
56
 
57
57
 
58
58
  # Holds the todo access context for tools.
@@ -83,7 +83,7 @@ def reset_tool_context(token: ToolContextToken) -> None:
83
83
 
84
84
 
85
85
  @contextmanager
86
- def tool_context(file_tracker: MutableMapping[str, float], todo_ctx: TodoContext) -> Generator[ToolContextToken]:
86
+ def tool_context(file_tracker: FileTracker, todo_ctx: TodoContext) -> Generator[ToolContextToken]:
87
87
  """Context manager for setting and resetting tool execution context."""
88
88
 
89
89
  file_tracker_token = current_file_tracker_var.set(file_tracker)
@@ -102,7 +102,7 @@ def build_todo_context(session: Session) -> TodoContext:
102
102
  return TodoContext(get_todos=store.get, set_todos=store.set)
103
103
 
104
104
 
105
- def get_current_file_tracker() -> MutableMapping[str, float] | None:
105
+ def get_current_file_tracker() -> FileTracker | None:
106
106
  """Return the current file tracker mapping for this tool context."""
107
107
 
108
108
  return current_file_tracker_var.get()
@@ -66,7 +66,7 @@ def load_agent_tools(
66
66
 
67
67
  # Main agent tools
68
68
  if "gpt-5" in model_name:
69
- tool_names = [tools.BASH, tools.APPLY_PATCH, tools.UPDATE_PLAN]
69
+ tool_names = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
70
70
  elif "gemini-3" in model_name:
71
71
  tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
72
72
  else:
@@ -4,13 +4,9 @@ from dataclasses import dataclass
4
4
 
5
5
  from klaude_code import const
6
6
  from klaude_code.core.tool.report_back_tool import ReportBackTool
7
- from klaude_code.core.tool.tool_abc import ToolABC
7
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy
8
8
  from klaude_code.core.tool.truncation import truncate_tool_output
9
9
  from klaude_code.protocol import model, tools
10
- from klaude_code.protocol.sub_agent import is_sub_agent_tool
11
-
12
- # Tools that can run concurrently (IO-bound, no local state mutations)
13
- _CONCURRENT_TOOLS: frozenset[str] = frozenset({tools.WEB_SEARCH, tools.WEB_FETCH})
14
10
 
15
11
 
16
12
  async def run_tool(tool_call: model.ToolCallItem, registry: dict[str, type[ToolABC]]) -> model.ToolResultItem:
@@ -214,14 +210,18 @@ class ToolExecutor:
214
210
 
215
211
  task.add_done_callback(_cleanup)
216
212
 
217
- @staticmethod
218
213
  def _partition_tool_calls(
214
+ self,
219
215
  tool_calls: list[model.ToolCallItem],
220
216
  ) -> tuple[list[model.ToolCallItem], list[model.ToolCallItem]]:
221
217
  sequential_tool_calls: list[model.ToolCallItem] = []
222
218
  concurrent_tool_calls: list[model.ToolCallItem] = []
223
219
  for tool_call in tool_calls:
224
- if is_sub_agent_tool(tool_call.name) or tool_call.name in _CONCURRENT_TOOLS:
220
+ tool_cls = self._registry.get(tool_call.name)
221
+ policy = (
222
+ tool_cls.metadata().concurrency_policy if tool_cls is not None else ToolConcurrencyPolicy.SEQUENTIAL
223
+ )
224
+ if policy == ToolConcurrencyPolicy.CONCURRENT:
225
225
  concurrent_tool_calls.append(tool_call)
226
226
  else:
227
227
  sequential_tool_calls.append(tool_call)
@@ -6,12 +6,12 @@ import urllib.error
6
6
  import urllib.request
7
7
  from http.client import HTTPResponse
8
8
  from pathlib import Path
9
- from urllib.parse import urlparse
9
+ from urllib.parse import quote, urlparse, urlunparse
10
10
 
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from klaude_code import const
14
- from klaude_code.core.tool.tool_abc import ToolABC, load_desc
14
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
15
15
  from klaude_code.core.tool.tool_registry import register
16
16
  from klaude_code.protocol import llm_param, model, tools
17
17
 
@@ -20,15 +20,70 @@ DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
20
20
  WEB_FETCH_SAVE_DIR = Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "web"
21
21
 
22
22
 
23
- def _extract_content_type(response: HTTPResponse) -> str:
24
- """Extract the base content type without charset parameters."""
25
- content_type = response.getheader("Content-Type", "")
26
- return content_type.split(";")[0].strip().lower()
23
+ def _encode_url(url: str) -> str:
24
+ """Encode non-ASCII characters in URL to make it safe for HTTP requests."""
25
+ parsed = urlparse(url)
26
+ encoded_path = quote(parsed.path, safe="/-_.~")
27
+ encoded_query = quote(parsed.query, safe="=&-_.~")
28
+ # Handle IDN (Internationalized Domain Names) by encoding to punycode
29
+ try:
30
+ netloc = parsed.netloc.encode("idna").decode("ascii")
31
+ except UnicodeError:
32
+ netloc = parsed.netloc
33
+ return urlunparse((parsed.scheme, netloc, encoded_path, parsed.params, encoded_query, parsed.fragment))
34
+
35
+
36
+ def _extract_content_type_and_charset(response: HTTPResponse) -> tuple[str, str | None]:
37
+ """Extract the base content type and charset from Content-Type header."""
38
+ content_type_header = response.getheader("Content-Type", "")
39
+ parts = content_type_header.split(";")
40
+ content_type = parts[0].strip().lower()
41
+
42
+ charset = None
43
+ for part in parts[1:]:
44
+ part = part.strip()
45
+ if part.lower().startswith("charset="):
46
+ charset = part[8:].strip().strip("\"'")
47
+ break
48
+
49
+ return content_type, charset
50
+
51
+
52
+ def _detect_encoding(data: bytes, declared_charset: str | None) -> str:
53
+ """Detect the encoding of the data."""
54
+ # 1. Use declared charset from HTTP header if available
55
+ if declared_charset:
56
+ return declared_charset
57
+
58
+ # 2. Try to detect from HTML meta tags (check first 2KB)
59
+ head = data[:2048].lower()
60
+ # <meta charset="xxx">
61
+ if match := re.search(rb'<meta[^>]+charset=["\']?([^"\'\s>]+)', head):
62
+ return match.group(1).decode("ascii", errors="ignore")
63
+ # <meta http-equiv="Content-Type" content="text/html; charset=xxx">
64
+ if match := re.search(rb'content=["\'][^"\']*charset=([^"\'\s;]+)', head):
65
+ return match.group(1).decode("ascii", errors="ignore")
66
+
67
+ # 3. Use chardet for automatic detection
68
+ import chardet
27
69
 
70
+ result = chardet.detect(data)
71
+ if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
72
+ return result["encoding"]
28
73
 
29
- def _validate_utf8(data: bytes) -> str:
30
- """Validate and decode bytes as UTF-8."""
31
- return data.decode("utf-8")
74
+ # 4. Default to UTF-8
75
+ return "utf-8"
76
+
77
+
78
+ def _decode_content(data: bytes, declared_charset: str | None) -> str:
79
+ """Decode bytes to string with automatic encoding detection."""
80
+ encoding = _detect_encoding(data, declared_charset)
81
+
82
+ try:
83
+ return data.decode(encoding)
84
+ except (UnicodeDecodeError, LookupError):
85
+ # Fallback: try UTF-8 with replacement for invalid chars
86
+ return data.decode("utf-8", errors="replace")
32
87
 
33
88
 
34
89
  def _convert_html_to_markdown(html: str) -> str:
@@ -98,17 +153,22 @@ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
98
153
  "Accept": "text/markdown, */*",
99
154
  "User-Agent": DEFAULT_USER_AGENT,
100
155
  }
101
- request = urllib.request.Request(url, headers=headers)
156
+ encoded_url = _encode_url(url)
157
+ request = urllib.request.Request(encoded_url, headers=headers)
102
158
 
103
159
  with urllib.request.urlopen(request, timeout=timeout) as response:
104
- content_type = _extract_content_type(response)
160
+ content_type, charset = _extract_content_type_and_charset(response)
105
161
  data = response.read()
106
- text = _validate_utf8(data)
162
+ text = _decode_content(data, charset)
107
163
  return content_type, text
108
164
 
109
165
 
110
166
  @register(tools.WEB_FETCH)
111
167
  class WebFetchTool(ToolABC):
168
+ @classmethod
169
+ def metadata(cls) -> ToolMetadata:
170
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
171
+
112
172
  @classmethod
113
173
  def schema(cls) -> llm_param.ToolSchema:
114
174
  return llm_param.ToolSchema(
@@ -149,7 +209,7 @@ class WebFetchTool(ToolABC):
149
209
  if not url.startswith(("http://", "https://")):
150
210
  return model.ToolResultItem(
151
211
  status="error",
152
- output="Invalid URL: must start with http:// or https://",
212
+ output=f"Invalid URL: must start with http:// or https:// (url={url})",
153
213
  )
154
214
 
155
215
  try:
@@ -170,25 +230,20 @@ class WebFetchTool(ToolABC):
170
230
  except urllib.error.HTTPError as e:
171
231
  return model.ToolResultItem(
172
232
  status="error",
173
- output=f"HTTP error {e.code}: {e.reason}",
233
+ output=f"HTTP error {e.code}: {e.reason} (url={url})",
174
234
  )
175
235
  except urllib.error.URLError as e:
176
236
  return model.ToolResultItem(
177
237
  status="error",
178
- output=f"URL error: {e.reason}",
179
- )
180
- except UnicodeDecodeError as e:
181
- return model.ToolResultItem(
182
- status="error",
183
- output=f"Content is not valid UTF-8: {e}",
238
+ output=f"URL error: {e.reason} (url={url})",
184
239
  )
185
240
  except TimeoutError:
186
241
  return model.ToolResultItem(
187
242
  status="error",
188
- output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
243
+ output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds (url={url})",
189
244
  )
190
245
  except Exception as e:
191
246
  return model.ToolResultItem(
192
247
  status="error",
193
- output=f"Failed to fetch URL: {e}",
248
+ output=f"Failed to fetch URL: {e} (url={url})",
194
249
  )
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from pydantic import BaseModel
6
6
 
7
- from klaude_code.core.tool.tool_abc import ToolABC, load_desc
7
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
8
8
  from klaude_code.core.tool.tool_registry import register
9
9
  from klaude_code.protocol import llm_param, model, tools
10
10
 
@@ -62,6 +62,10 @@ def _format_results(results: list[SearchResult]) -> str:
62
62
 
63
63
  @register(tools.WEB_SEARCH)
64
64
  class WebSearchTool(ToolABC):
65
+ @classmethod
66
+ def metadata(cls) -> ToolMetadata:
67
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=False)
68
+
65
69
  @classmethod
66
70
  def schema(cls) -> llm_param.ToolSchema:
67
71
  return llm_param.ToolSchema(
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import os
2
3
  from collections.abc import AsyncGenerator
3
4
  from typing import override
4
5
 
@@ -61,11 +62,20 @@ def build_payload(param: llm_param.LLMCallParameter) -> MessageCreateParamsStrea
61
62
  class AnthropicClient(LLMClientABC):
62
63
  def __init__(self, config: llm_param.LLMConfigParameter):
63
64
  super().__init__(config)
64
- client = anthropic.AsyncAnthropic(
65
- api_key=config.api_key,
66
- base_url=config.base_url,
67
- timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
68
- )
65
+ # Remove ANTHROPIC_AUTH_TOKEN env var to prevent anthropic SDK from adding
66
+ # Authorization: Bearer header that may conflict with third-party APIs
67
+ # (e.g., deepseek, moonshot) that use Authorization header for authentication.
68
+ # The API key will be sent via X-Api-Key header instead.
69
+ saved_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
70
+ try:
71
+ client = anthropic.AsyncAnthropic(
72
+ api_key=config.api_key,
73
+ base_url=config.base_url,
74
+ timeout=httpx.Timeout(300.0, connect=15.0, read=285.0),
75
+ )
76
+ finally:
77
+ if saved_auth_token is not None:
78
+ os.environ["ANTHROPIC_AUTH_TOKEN"] = saved_auth_token
69
79
  self.client: anthropic.AsyncAnthropic = client
70
80
 
71
81
  @classmethod
@@ -120,35 +130,38 @@ class AnthropicClient(LLMClientABC):
120
130
  case BetaRawContentBlockDeltaEvent() as event:
121
131
  match event.delta:
122
132
  case BetaThinkingDelta() as delta:
123
- metadata_tracker.record_token()
133
+ if delta.thinking:
134
+ metadata_tracker.record_token()
124
135
  accumulated_thinking.append(delta.thinking)
125
136
  yield model.ReasoningTextDelta(
126
137
  content=delta.thinking,
127
138
  response_id=response_id,
128
139
  )
129
140
  case BetaSignatureDelta() as delta:
130
- metadata_tracker.record_token()
131
141
  yield model.ReasoningEncryptedItem(
132
142
  encrypted_content=delta.signature,
133
143
  response_id=response_id,
134
144
  model=str(param.model),
135
145
  )
136
146
  case BetaTextDelta() as delta:
137
- metadata_tracker.record_token()
147
+ if delta.text:
148
+ metadata_tracker.record_token()
138
149
  accumulated_content.append(delta.text)
139
150
  yield model.AssistantMessageDelta(
140
151
  content=delta.text,
141
152
  response_id=response_id,
142
153
  )
143
154
  case BetaInputJSONDelta() as delta:
144
- metadata_tracker.record_token()
145
155
  if current_tool_inputs is not None:
156
+ if delta.partial_json:
157
+ metadata_tracker.record_token()
146
158
  current_tool_inputs.append(delta.partial_json)
147
159
  case _:
148
160
  pass
149
161
  case BetaRawContentBlockStartEvent() as event:
150
162
  match event.content_block:
151
163
  case BetaToolUseBlock() as block:
164
+ metadata_tracker.record_token()
152
165
  yield model.ToolCallStartItem(
153
166
  response_id=response_id,
154
167
  call_id=block.id,
@@ -161,6 +174,7 @@ class AnthropicClient(LLMClientABC):
161
174
  pass
162
175
  case BetaRawContentBlockStopEvent() as event:
163
176
  if len(accumulated_thinking) > 0:
177
+ metadata_tracker.record_token()
164
178
  full_thinking = "".join(accumulated_thinking)
165
179
  yield model.ReasoningTextItem(
166
180
  content=full_thinking,
@@ -169,12 +183,14 @@ class AnthropicClient(LLMClientABC):
169
183
  )
170
184
  accumulated_thinking.clear()
171
185
  if len(accumulated_content) > 0:
186
+ metadata_tracker.record_token()
172
187
  yield model.AssistantMessageItem(
173
188
  content="".join(accumulated_content),
174
189
  response_id=response_id,
175
190
  )
176
191
  accumulated_content.clear()
177
192
  if current_tool_name and current_tool_call_id:
193
+ metadata_tracker.record_token()
178
194
  yield model.ToolCallItem(
179
195
  name=current_tool_name,
180
196
  call_id=current_tool_call_id,
@@ -23,7 +23,7 @@ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreatePa
23
23
 
24
24
  extra_body: dict[str, object] = {}
25
25
 
26
- if param.thinking:
26
+ if param.thinking and param.thinking.type == "enabled":
27
27
  extra_body["thinking"] = {
28
28
  "type": param.thinking.type,
29
29
  "budget": param.thinking.budget_tokens,
@@ -182,7 +182,10 @@ class OpenAICompatibleClient(LLMClientABC):
182
182
  yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
183
183
 
184
184
  # Finalize
185
- for item in state.flush_all():
185
+ flushed_items = state.flush_all()
186
+ if flushed_items:
187
+ metadata_tracker.record_token()
188
+ for item in flushed_items:
186
189
  yield item
187
190
 
188
191
  metadata_tracker.set_response_id(state.response_id)
@@ -35,7 +35,7 @@ def build_payload(
35
35
  extra_headers: dict[str, str] = {}
36
36
 
37
37
  if param.thinking:
38
- if param.thinking.budget_tokens is not None:
38
+ if param.thinking.type != "disabled" and param.thinking.budget_tokens is not None:
39
39
  extra_body["reasoning"] = {
40
40
  "max_tokens": param.thinking.budget_tokens,
41
41
  "enable": True,
@@ -139,7 +139,8 @@ class OpenRouterClient(LLMClientABC):
139
139
  for item in reasoning_details:
140
140
  try:
141
141
  reasoning_detail = ReasoningDetail.model_validate(item)
142
- metadata_tracker.record_token()
142
+ if reasoning_detail.text or reasoning_detail.summary:
143
+ metadata_tracker.record_token()
143
144
  state.stage = "reasoning"
144
145
  # Yield delta immediately for streaming
145
146
  if reasoning_detail.text:
@@ -198,7 +199,10 @@ class OpenRouterClient(LLMClientABC):
198
199
  yield model.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
199
200
 
200
201
  # Finalize
201
- for item in state.flush_all():
202
+ flushed_items = state.flush_all()
203
+ if flushed_items:
204
+ metadata_tracker.record_token()
205
+ for item in flushed_items:
202
206
  yield item
203
207
 
204
208
  metadata_tracker.set_response_id(state.response_id)
@@ -77,6 +77,7 @@ async def parse_responses_stream(
77
77
  yield model.StartItem(response_id=response_id)
78
78
  case responses.ResponseReasoningSummaryTextDeltaEvent() as event:
79
79
  if event.delta:
80
+ metadata_tracker.record_token()
80
81
  yield model.ReasoningTextDelta(
81
82
  content=event.delta,
82
83
  response_id=response_id,
@@ -89,10 +90,12 @@ async def parse_responses_stream(
89
90
  model=str(param.model),
90
91
  )
91
92
  case responses.ResponseTextDeltaEvent() as event:
92
- metadata_tracker.record_token()
93
+ if event.delta:
94
+ metadata_tracker.record_token()
93
95
  yield model.AssistantMessageDelta(content=event.delta, response_id=response_id)
94
96
  case responses.ResponseOutputItemAddedEvent() as event:
95
97
  if isinstance(event.item, responses.ResponseFunctionToolCall):
98
+ metadata_tracker.record_token()
96
99
  yield model.ToolCallStartItem(
97
100
  response_id=response_id,
98
101
  call_id=event.item.call_id,
@@ -102,6 +105,7 @@ async def parse_responses_stream(
102
105
  match event.item:
103
106
  case responses.ResponseReasoningItem() as item:
104
107
  if item.encrypted_content:
108
+ metadata_tracker.record_token()
105
109
  yield model.ReasoningEncryptedItem(
106
110
  id=item.id,
107
111
  encrypted_content=item.encrypted_content,
@@ -109,6 +113,7 @@ async def parse_responses_stream(
109
113
  model=str(param.model),
110
114
  )
111
115
  case responses.ResponseOutputMessage() as item:
116
+ metadata_tracker.record_token()
112
117
  yield model.AssistantMessageItem(
113
118
  content="\n".join(
114
119
  [
@@ -69,6 +69,13 @@ class TodoItem(BaseModel):
69
69
  active_form: str = Field(default="", alias="activeForm")
70
70
 
71
71
 
72
+ class FileStatus(BaseModel):
73
+ """Tracks file state including modification time and memory file flag."""
74
+
75
+ mtime: float
76
+ is_memory: bool = False
77
+
78
+
72
79
  class TodoUIExtra(BaseModel):
73
80
  todos: list[TodoItem]
74
81
  new_completed: list[str]
@@ -170,7 +177,7 @@ A conversation history input contains:
170
177
  - [DeveloperMessageItem]
171
178
 
172
179
  When adding a new item, please also modify the following:
173
- - session.py#_TypeMap
180
+ - session/codec.py (ConversationItem registry derived from ConversationItem union)
174
181
  """
175
182
 
176
183
 
@@ -23,6 +23,10 @@ class OperationType(Enum):
23
23
  """Enumeration of supported operation types."""
24
24
 
25
25
  USER_INPUT = "user_input"
26
+ RUN_AGENT = "run_agent"
27
+ CHANGE_MODEL = "change_model"
28
+ CLEAR_SESSION = "clear_session"
29
+ EXPORT_SESSION = "export_session"
26
30
  INTERRUPT = "interrupt"
27
31
  INIT_AGENT = "init_agent"
28
32
  END = "end"
@@ -51,6 +55,49 @@ class UserInputOperation(Operation):
51
55
  await handler.handle_user_input(self)
52
56
 
53
57
 
58
+ class RunAgentOperation(Operation):
59
+ """Operation for launching an agent task for a given session."""
60
+
61
+ type: OperationType = OperationType.RUN_AGENT
62
+ session_id: str
63
+ input: UserInputPayload
64
+
65
+ async def execute(self, handler: OperationHandler) -> None:
66
+ await handler.handle_run_agent(self)
67
+
68
+
69
+ class ChangeModelOperation(Operation):
70
+ """Operation for changing the model used by the active agent session."""
71
+
72
+ type: OperationType = OperationType.CHANGE_MODEL
73
+ session_id: str
74
+ model_name: str
75
+
76
+ async def execute(self, handler: OperationHandler) -> None:
77
+ await handler.handle_change_model(self)
78
+
79
+
80
+ class ClearSessionOperation(Operation):
81
+ """Operation for clearing the active session and starting a new one."""
82
+
83
+ type: OperationType = OperationType.CLEAR_SESSION
84
+ session_id: str
85
+
86
+ async def execute(self, handler: OperationHandler) -> None:
87
+ await handler.handle_clear_session(self)
88
+
89
+
90
+ class ExportSessionOperation(Operation):
91
+ """Operation for exporting a session transcript to HTML."""
92
+
93
+ type: OperationType = OperationType.EXPORT_SESSION
94
+ session_id: str
95
+ output_path: str | None = None
96
+
97
+ async def execute(self, handler: OperationHandler) -> None:
98
+ await handler.handle_export_session(self)
99
+
100
+
54
101
  class InterruptOperation(Operation):
55
102
  """Operation for interrupting currently running tasks."""
56
103
 
@@ -9,7 +9,15 @@ from __future__ import annotations
9
9
  from typing import TYPE_CHECKING, Protocol
10
10
 
11
11
  if TYPE_CHECKING:
12
- from klaude_code.protocol.op import InitAgentOperation, InterruptOperation, UserInputOperation
12
+ from klaude_code.protocol.op import (
13
+ ChangeModelOperation,
14
+ ClearSessionOperation,
15
+ ExportSessionOperation,
16
+ InitAgentOperation,
17
+ InterruptOperation,
18
+ RunAgentOperation,
19
+ UserInputOperation,
20
+ )
13
21
 
14
22
 
15
23
  class OperationHandler(Protocol):
@@ -19,6 +27,22 @@ class OperationHandler(Protocol):
19
27
  """Handle a user input operation."""
20
28
  ...
21
29
 
30
+ async def handle_run_agent(self, operation: RunAgentOperation) -> None:
31
+ """Handle a run agent operation."""
32
+ ...
33
+
34
+ async def handle_change_model(self, operation: ChangeModelOperation) -> None:
35
+ """Handle a change model operation."""
36
+ ...
37
+
38
+ async def handle_clear_session(self, operation: ClearSessionOperation) -> None:
39
+ """Handle a clear session operation."""
40
+ ...
41
+
42
+ async def handle_export_session(self, operation: ExportSessionOperation) -> None:
43
+ """Handle an export session operation."""
44
+ ...
45
+
22
46
  async def handle_interrupt(self, operation: InterruptOperation) -> None:
23
47
  """Handle an interrupt operation."""
24
48
  ...
@@ -71,7 +71,7 @@ register_sub_agent(
71
71
  description=WEB_AGENT_DESCRIPTION,
72
72
  parameters=WEB_AGENT_PARAMETERS,
73
73
  prompt_file="prompts/prompt-sub-agent-web.md",
74
- tool_set=(tools.BASH, tools.READ, tools.WEB_FETCH, tools.WEB_SEARCH),
74
+ tool_set=(tools.BASH, tools.READ, tools.WEB_FETCH, tools.WEB_SEARCH, tools.WRITE),
75
75
  prompt_builder=_web_agent_prompt_builder,
76
76
  active_form="Surfing",
77
77
  output_schema_arg="output_format",
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, TypeGuard, cast, get_args
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from klaude_code.protocol import model
9
+
10
+
11
+ def _is_basemodel_subclass(tp: object) -> TypeGuard[type[BaseModel]]:
12
+ return isinstance(tp, type) and issubclass(tp, BaseModel)
13
+
14
+
15
+ def _flatten_union(tp: object) -> list[object]:
16
+ args = list(get_args(tp))
17
+ if not args:
18
+ return [tp]
19
+ flattened: list[object] = []
20
+ for arg in args:
21
+ flattened.extend(_flatten_union(arg))
22
+ return flattened
23
+
24
+
25
+ def _build_type_registry() -> dict[str, type[BaseModel]]:
26
+ registry: dict[str, type[BaseModel]] = {}
27
+ for tp in _flatten_union(model.ConversationItem):
28
+ if not _is_basemodel_subclass(tp):
29
+ continue
30
+ registry[tp.__name__] = tp
31
+ return registry
32
+
33
+
34
+ _CONVERSATION_ITEM_TYPES: dict[str, type[BaseModel]] = _build_type_registry()
35
+
36
+
37
+ def encode_conversation_item(item: model.ConversationItem) -> dict[str, Any]:
38
+ return {"type": item.__class__.__name__, "data": item.model_dump(mode="json")}
39
+
40
+
41
+ def decode_conversation_item(obj: dict[str, Any]) -> model.ConversationItem | None:
42
+ t = obj.get("type")
43
+ data = obj.get("data", {})
44
+ if not isinstance(t, str) or not isinstance(data, dict):
45
+ return None
46
+ cls = _CONVERSATION_ITEM_TYPES.get(t)
47
+ if cls is None:
48
+ return None
49
+ try:
50
+ item = cls(**data)
51
+ except TypeError:
52
+ return None
53
+ # pyright: ignore[reportReturnType]
54
+ return item # type: ignore[return-value]
55
+
56
+
57
+ def encode_jsonl_line(item: model.ConversationItem) -> str:
58
+ return json.dumps(encode_conversation_item(item), ensure_ascii=False) + "\n"
59
+
60
+
61
+ def decode_jsonl_line(line: str) -> model.ConversationItem | None:
62
+ line = line.strip()
63
+ if not line:
64
+ return None
65
+ try:
66
+ obj = json.loads(line)
67
+ except json.JSONDecodeError:
68
+ return None
69
+ if not isinstance(obj, dict):
70
+ return None
71
+ return decode_conversation_item(cast(dict[str, Any], obj))