klaude-code 1.2.7__py3-none-any.whl → 1.2.9__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 (52) hide show
  1. klaude_code/auth/codex/__init__.py +1 -1
  2. klaude_code/command/__init__.py +2 -0
  3. klaude_code/command/prompt-deslop.md +14 -0
  4. klaude_code/command/release_notes_cmd.py +86 -0
  5. klaude_code/command/status_cmd.py +92 -54
  6. klaude_code/core/agent.py +13 -19
  7. klaude_code/core/manager/sub_agent_manager.py +5 -1
  8. klaude_code/core/prompt.py +38 -28
  9. klaude_code/core/reminders.py +4 -4
  10. klaude_code/core/task.py +60 -45
  11. klaude_code/core/tool/__init__.py +2 -0
  12. klaude_code/core/tool/file/apply_patch_tool.py +1 -1
  13. klaude_code/core/tool/file/edit_tool.py +1 -1
  14. klaude_code/core/tool/file/multi_edit_tool.py +1 -1
  15. klaude_code/core/tool/file/write_tool.py +1 -1
  16. klaude_code/core/tool/memory/memory_tool.py +2 -2
  17. klaude_code/core/tool/sub_agent_tool.py +2 -1
  18. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  19. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  20. klaude_code/core/tool/tool_context.py +21 -4
  21. klaude_code/core/tool/tool_runner.py +5 -8
  22. klaude_code/core/tool/web/mermaid_tool.py +1 -4
  23. klaude_code/core/turn.py +90 -62
  24. klaude_code/llm/anthropic/client.py +15 -46
  25. klaude_code/llm/client.py +1 -1
  26. klaude_code/llm/codex/client.py +44 -30
  27. klaude_code/llm/input_common.py +0 -6
  28. klaude_code/llm/openai_compatible/client.py +29 -73
  29. klaude_code/llm/openai_compatible/input.py +6 -4
  30. klaude_code/llm/openai_compatible/stream_processor.py +82 -0
  31. klaude_code/llm/openrouter/client.py +29 -59
  32. klaude_code/llm/openrouter/input.py +4 -27
  33. klaude_code/llm/responses/client.py +49 -79
  34. klaude_code/llm/usage.py +51 -10
  35. klaude_code/protocol/commands.py +1 -0
  36. klaude_code/protocol/events.py +12 -2
  37. klaude_code/protocol/model.py +142 -26
  38. klaude_code/protocol/sub_agent.py +5 -1
  39. klaude_code/session/export.py +51 -27
  40. klaude_code/session/session.py +33 -16
  41. klaude_code/session/templates/export_session.html +4 -1
  42. klaude_code/ui/modes/repl/__init__.py +1 -5
  43. klaude_code/ui/modes/repl/event_handler.py +153 -54
  44. klaude_code/ui/modes/repl/renderer.py +6 -4
  45. klaude_code/ui/renderers/developer.py +35 -25
  46. klaude_code/ui/renderers/metadata.py +68 -30
  47. klaude_code/ui/renderers/tools.py +53 -87
  48. klaude_code/ui/rich/markdown.py +5 -5
  49. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/METADATA +1 -1
  50. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/RECORD +52 -49
  51. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/WHEEL +0 -0
  52. {klaude_code-1.2.7.dist-info → klaude_code-1.2.9.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  from datetime import datetime
2
2
  from enum import Enum
3
- from typing import Literal
3
+ from typing import Annotated, Literal
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
6
6
 
7
7
  from klaude_code.protocol.commands import CommandName
8
8
  from klaude_code.protocol.tools import SubAgentType
@@ -12,12 +12,16 @@ TodoStatusType = Literal["pending", "in_progress", "completed"]
12
12
 
13
13
 
14
14
  class Usage(BaseModel):
15
+ # Token Usage (primary state)
15
16
  input_tokens: int = 0
16
17
  cached_tokens: int = 0
17
18
  reasoning_tokens: int = 0
18
19
  output_tokens: int = 0
19
- total_tokens: int = 0
20
- context_usage_percent: float | None = None
20
+
21
+ # Context window tracking
22
+ context_window_size: int | None = None # Peak total_tokens seen (for context usage display)
23
+ context_limit: int | None = None # Model's context limit
24
+
21
25
  throughput_tps: float | None = None
22
26
  first_token_latency_ms: float | None = None
23
27
 
@@ -25,14 +29,39 @@ class Usage(BaseModel):
25
29
  input_cost: float | None = None # Cost for non-cached input tokens
26
30
  output_cost: float | None = None # Cost for output tokens (including reasoning)
27
31
  cache_read_cost: float | None = None # Cost for cached tokens
28
- total_cost: float | None = None # Total cost (input + output + cache_read)
29
32
  currency: str = "USD" # Currency for cost display (USD or CNY)
30
33
 
34
+ @computed_field # type: ignore[prop-decorator]
35
+ @property
36
+ def total_tokens(self) -> int:
37
+ """Total tokens computed from input + output tokens."""
38
+ return self.input_tokens + self.output_tokens
39
+
40
+ @computed_field # type: ignore[prop-decorator]
41
+ @property
42
+ def total_cost(self) -> float | None:
43
+ """Total cost computed from input + output + cache_read costs."""
44
+ costs = [self.input_cost, self.output_cost, self.cache_read_cost]
45
+ non_none = [c for c in costs if c is not None]
46
+ return sum(non_none) if non_none else None
47
+
48
+ @computed_field # type: ignore[prop-decorator]
49
+ @property
50
+ def context_usage_percent(self) -> float | None:
51
+ """Context usage percentage computed from context_window_size / context_limit."""
52
+ if self.context_limit is None or self.context_limit <= 0:
53
+ return None
54
+ if self.context_window_size is None:
55
+ return None
56
+ return (self.context_window_size / self.context_limit) * 100
57
+
31
58
 
32
59
  class TodoItem(BaseModel):
60
+ model_config = ConfigDict(populate_by_name=True)
61
+
33
62
  content: str
34
63
  status: TodoStatusType
35
- activeForm: str = ""
64
+ active_form: str = Field(default="", alias="activeForm")
36
65
 
37
66
 
38
67
  class TodoUIExtra(BaseModel):
@@ -40,43 +69,55 @@ class TodoUIExtra(BaseModel):
40
69
  new_completed: list[str]
41
70
 
42
71
 
43
- class ToolResultUIExtraType(str, Enum):
44
- DIFF_TEXT = "diff_text"
45
- TODO_LIST = "todo_list"
46
- SESSION_ID = "session_id"
47
- MERMAID_LINK = "mermaid_link"
48
- TRUNCATION = "truncation"
49
- SESSION_STATUS = "session_status"
50
-
51
-
52
72
  class ToolSideEffect(str, Enum):
53
73
  TODO_CHANGE = "todo_change"
54
74
 
55
75
 
76
+ # Discriminated union types for ToolResultUIExtra
77
+ class DiffTextUIExtra(BaseModel):
78
+ type: Literal["diff_text"] = "diff_text"
79
+ diff_text: str
80
+
81
+
82
+ class TodoListUIExtra(BaseModel):
83
+ type: Literal["todo_list"] = "todo_list"
84
+ todo_list: TodoUIExtra
85
+
86
+
87
+ class SessionIdUIExtra(BaseModel):
88
+ type: Literal["session_id"] = "session_id"
89
+ session_id: str
90
+
91
+
56
92
  class MermaidLinkUIExtra(BaseModel):
93
+ type: Literal["mermaid_link"] = "mermaid_link"
57
94
  link: str
58
95
  line_count: int
59
96
 
60
97
 
61
98
  class TruncationUIExtra(BaseModel):
99
+ type: Literal["truncation"] = "truncation"
62
100
  saved_file_path: str
63
101
  original_length: int
64
102
  truncated_length: int
65
103
 
66
104
 
67
105
  class SessionStatusUIExtra(BaseModel):
106
+ type: Literal["session_status"] = "session_status"
68
107
  usage: "Usage"
69
108
  task_count: int
109
+ by_model: list["TaskMetadata"] = []
70
110
 
71
111
 
72
- class ToolResultUIExtra(BaseModel):
73
- type: ToolResultUIExtraType
74
- diff_text: str | None = None
75
- todo_list: TodoUIExtra | None = None
76
- session_id: str | None = None
77
- mermaid_link: MermaidLinkUIExtra | None = None
78
- truncation: TruncationUIExtra | None = None
79
- session_status: SessionStatusUIExtra | None = None
112
+ ToolResultUIExtra = Annotated[
113
+ DiffTextUIExtra
114
+ | TodoListUIExtra
115
+ | SessionIdUIExtra
116
+ | MermaidLinkUIExtra
117
+ | TruncationUIExtra
118
+ | SessionStatusUIExtra,
119
+ Field(discriminator="type"),
120
+ ]
80
121
 
81
122
 
82
123
  class AtPatternParseResult(BaseModel):
@@ -240,6 +281,7 @@ class ToolResultItem(BaseModel):
240
281
  ui_extra: ToolResultUIExtra | None = None # Extra data for UI display, e.g. diff render
241
282
  images: list[ImageURLPart] | None = None
242
283
  side_effects: list[ToolSideEffect] | None = None
284
+ task_metadata: "TaskMetadata | None" = None # Sub-agent task metadata for propagation to main agent
243
285
  created_at: datetime = Field(default_factory=datetime.now)
244
286
 
245
287
 
@@ -255,13 +297,80 @@ class StreamErrorItem(BaseModel):
255
297
 
256
298
 
257
299
  class ResponseMetadataItem(BaseModel):
300
+ """Metadata for a single LLM response (turn-level)."""
301
+
258
302
  response_id: str | None = None
259
303
  usage: Usage | None = None
260
304
  model_name: str = ""
261
305
  provider: str | None = None # OpenRouter's provider name
262
306
  task_duration_s: float | None = None
263
- status: str | None = None
264
- error_reason: str | None = None
307
+ created_at: datetime = Field(default_factory=datetime.now)
308
+
309
+
310
+ class TaskMetadata(BaseModel):
311
+ """Base metadata for a task execution (used by both main and sub-agents)."""
312
+
313
+ usage: Usage | None = None
314
+ model_name: str = ""
315
+ provider: str | None = None
316
+ task_duration_s: float | None = None
317
+
318
+ @staticmethod
319
+ def aggregate_by_model(metadata_list: list["TaskMetadata"]) -> list["TaskMetadata"]:
320
+ """Aggregate multiple TaskMetadata by (model_name, provider).
321
+
322
+ Returns a list sorted by total_cost descending.
323
+
324
+ Note: total_tokens and total_cost are now computed fields,
325
+ so we only accumulate the primary state fields here.
326
+ """
327
+ aggregated: dict[tuple[str, str | None], TaskMetadata] = {}
328
+
329
+ for meta in metadata_list:
330
+ if not meta.usage:
331
+ continue
332
+
333
+ key = (meta.model_name, meta.provider)
334
+ usage = meta.usage
335
+
336
+ if key not in aggregated:
337
+ aggregated[key] = TaskMetadata(
338
+ model_name=meta.model_name,
339
+ provider=meta.provider,
340
+ usage=Usage(currency=usage.currency),
341
+ )
342
+
343
+ agg = aggregated[key]
344
+ if agg.usage is None:
345
+ continue
346
+
347
+ # Accumulate primary token fields (total_tokens is computed)
348
+ agg.usage.input_tokens += usage.input_tokens
349
+ agg.usage.cached_tokens += usage.cached_tokens
350
+ agg.usage.reasoning_tokens += usage.reasoning_tokens
351
+ agg.usage.output_tokens += usage.output_tokens
352
+
353
+ # Accumulate cost components (total_cost is computed)
354
+ if usage.input_cost is not None:
355
+ agg.usage.input_cost = (agg.usage.input_cost or 0.0) + usage.input_cost
356
+ if usage.output_cost is not None:
357
+ agg.usage.output_cost = (agg.usage.output_cost or 0.0) + usage.output_cost
358
+ if usage.cache_read_cost is not None:
359
+ agg.usage.cache_read_cost = (agg.usage.cache_read_cost or 0.0) + usage.cache_read_cost
360
+
361
+ # Sort by total_cost descending
362
+ return sorted(
363
+ aggregated.values(),
364
+ key=lambda m: m.usage.total_cost if m.usage and m.usage.total_cost else 0.0,
365
+ reverse=True,
366
+ )
367
+
368
+
369
+ class TaskMetadataItem(BaseModel):
370
+ """Aggregated metadata for a complete task, stored in conversation history."""
371
+
372
+ main: TaskMetadata = Field(default_factory=TaskMetadata)
373
+ sub_agent_task_metadata: list[TaskMetadata] = Field(default_factory=lambda: list[TaskMetadata]())
265
374
  created_at: datetime = Field(default_factory=datetime.now)
266
375
 
267
376
 
@@ -280,7 +389,14 @@ MessageItem = (
280
389
  StreamItem = AssistantMessageDelta
281
390
 
282
391
  ConversationItem = (
283
- StartItem | InterruptItem | StreamErrorItem | StreamItem | MessageItem | ResponseMetadataItem | ToolCallStartItem
392
+ StartItem
393
+ | InterruptItem
394
+ | StreamErrorItem
395
+ | StreamItem
396
+ | MessageItem
397
+ | ResponseMetadataItem
398
+ | TaskMetadataItem
399
+ | ToolCallStartItem
284
400
  )
285
401
 
286
402
 
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from typing import Any, Callable
4
+ from typing import TYPE_CHECKING, Any, Callable
5
5
 
6
6
  from klaude_code.protocol import tools
7
7
 
8
+ if TYPE_CHECKING:
9
+ from klaude_code.protocol import model
10
+
8
11
  AvailabilityPredicate = Callable[[str], bool]
9
12
  PromptBuilder = Callable[[dict[str, Any]], str]
10
13
 
@@ -14,6 +17,7 @@ class SubAgentResult:
14
17
  task_result: str
15
18
  session_id: str
16
19
  error: bool = False
20
+ task_metadata: model.TaskMetadata | None = None
17
21
 
18
22
 
19
23
  def _default_prompt_builder(args: dict[str, Any]) -> str:
@@ -159,20 +159,35 @@ def _format_cost(cost: float, currency: str = "USD") -> str:
159
159
  return f"{symbol}{cost:.4f}"
160
160
 
161
161
 
162
- def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
163
- # Model Name [@ Provider]
162
+ def _render_single_metadata(
163
+ metadata: model.TaskMetadata,
164
+ *,
165
+ indent: int = 0,
166
+ show_context: bool = True,
167
+ ) -> str:
168
+ """Render a single TaskMetadata block as HTML.
169
+
170
+ Args:
171
+ metadata: The TaskMetadata to render.
172
+ indent: Number of spaces to indent (0 for main, 2 for sub-agents).
173
+ show_context: Whether to show context usage percent.
174
+
175
+ Returns:
176
+ HTML string for this metadata block.
177
+ """
164
178
  parts: list[str] = []
165
179
 
166
- model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
167
- if item.provider:
168
- provider = _escape_html(item.provider.lower().replace(" ", "-"))
180
+ # Model Name [@ Provider]
181
+ model_parts = [f'<span class="metadata-model">{_escape_html(metadata.model_name)}</span>']
182
+ if metadata.provider:
183
+ provider = _escape_html(metadata.provider.lower().replace(" ", "-"))
169
184
  model_parts.append(f'<span class="metadata-provider">@{provider}</span>')
170
185
 
171
186
  parts.append("".join(model_parts))
172
187
 
173
188
  # Stats
174
- if item.usage:
175
- u = item.usage
189
+ if metadata.usage:
190
+ u = metadata.usage
176
191
  # Input with cost
177
192
  input_stat = f"input: {_format_token_count(u.input_tokens)}"
178
193
  if u.input_cost is not None:
@@ -194,22 +209,39 @@ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
194
209
 
195
210
  if u.reasoning_tokens > 0:
196
211
  parts.append(f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>')
197
- if u.context_usage_percent is not None:
212
+ if show_context and u.context_usage_percent is not None:
198
213
  parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
199
214
  if u.throughput_tps is not None:
200
215
  parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
201
216
 
202
- if item.task_duration_s is not None:
203
- parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
217
+ if metadata.task_duration_s is not None:
218
+ parts.append(f'<span class="metadata-stat">time: {metadata.task_duration_s:.1f}s</span>')
204
219
 
205
220
  # Total cost
206
- if item.usage is not None and item.usage.total_cost is not None:
207
- parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost, item.usage.currency)}</span>')
221
+ if metadata.usage is not None and metadata.usage.total_cost is not None:
222
+ parts.append(
223
+ f'<span class="metadata-stat">cost: {_format_cost(metadata.usage.total_cost, metadata.usage.currency)}</span>'
224
+ )
208
225
 
209
226
  divider = '<span class="metadata-divider">/</span>'
210
227
  joined_html = divider.join(parts)
211
228
 
212
- return f'<div class="response-metadata"><div class="metadata-line">{joined_html}</div></div>'
229
+ indent_style = f' style="padding-left: {indent}em;"' if indent > 0 else ""
230
+ return f'<div class="metadata-line"{indent_style}>{joined_html}</div>'
231
+
232
+
233
+ def _render_metadata_item(item: model.TaskMetadataItem) -> str:
234
+ """Render TaskMetadataItem including main agent and sub-agents."""
235
+ lines: list[str] = []
236
+
237
+ # Main agent metadata
238
+ lines.append(_render_single_metadata(item.main, indent=0, show_context=True))
239
+
240
+ # Sub-agent metadata with indent
241
+ for sub in item.sub_agent_task_metadata:
242
+ lines.append(_render_single_metadata(sub, indent=1, show_context=False))
243
+
244
+ return f'<div class="response-metadata">{"".join(lines)}</div>'
213
245
 
214
246
 
215
247
  def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
@@ -336,11 +368,9 @@ def _render_diff_block(diff: str) -> str:
336
368
 
337
369
 
338
370
  def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
339
- if ui_extra is None:
340
- return None
341
- if ui_extra.type != model.ToolResultUIExtraType.DIFF_TEXT:
342
- return None
343
- return ui_extra.diff_text
371
+ if isinstance(ui_extra, model.DiffTextUIExtra):
372
+ return ui_extra.diff_text
373
+ return None
344
374
 
345
375
 
346
376
  def _get_mermaid_link_html(
@@ -355,9 +385,7 @@ def _get_mermaid_link_html(
355
385
  else:
356
386
  code = ""
357
387
 
358
- if not code and (
359
- ui_extra is None or ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK or not ui_extra.mermaid_link
360
- ):
388
+ if not code and not isinstance(ui_extra, model.MermaidLinkUIExtra):
361
389
  return None
362
390
 
363
391
  # Prepare code for rendering and copy
@@ -376,11 +404,7 @@ def _get_mermaid_link_html(
376
404
  f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
377
405
  )
378
406
 
379
- link = (
380
- ui_extra.mermaid_link.link
381
- if (ui_extra and ui_extra.type == model.ToolResultUIExtraType.MERMAID_LINK and ui_extra.mermaid_link)
382
- else None
383
- )
407
+ link = ui_extra.link if isinstance(ui_extra, model.MermaidLinkUIExtra) else None
384
408
 
385
409
  if link:
386
410
  link_url = _escape_html(link)
@@ -544,7 +568,7 @@ def _build_messages_html(
544
568
  elif isinstance(item, model.AssistantMessageItem):
545
569
  assistant_counter += 1
546
570
  blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
547
- elif isinstance(item, model.ResponseMetadataItem):
571
+ elif isinstance(item, model.TaskMetadataItem):
548
572
  blocks.append(_render_metadata_item(item))
549
573
  elif isinstance(item, model.DeveloperMessageItem):
550
574
  content = _escape_html(item.content or "")
@@ -5,7 +5,7 @@ from collections.abc import Iterable, Sequence
5
5
  from pathlib import Path
6
6
  from typing import ClassVar
7
7
 
8
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, PrivateAttr
9
9
 
10
10
  from klaude_code.protocol import events, model
11
11
 
@@ -19,8 +19,6 @@ class Session(BaseModel):
19
19
  file_tracker: dict[str, float] = Field(default_factory=dict)
20
20
  # Todo list for the session
21
21
  todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
22
- # Messages count, redundant state for performance optimization to avoid reading entire jsonl file
23
- messages_count: int = Field(default=0)
24
22
  # Model name used for this session
25
23
  # Used in list method SessionMetaBrief
26
24
  model_name: str | None = None
@@ -33,6 +31,27 @@ class Session(BaseModel):
33
31
  need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
34
32
  need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
35
33
 
34
+ # Cached messages count (computed property)
35
+ _messages_count_cache: int | None = PrivateAttr(default=None)
36
+
37
+ @property
38
+ def messages_count(self) -> int:
39
+ """Count of user and assistant messages in conversation history.
40
+
41
+ This is a cached property that is invalidated when append_history is called.
42
+ """
43
+ if self._messages_count_cache is None:
44
+ self._messages_count_cache = sum(
45
+ 1
46
+ for it in self.conversation_history
47
+ if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
48
+ )
49
+ return self._messages_count_cache
50
+
51
+ def _invalidate_messages_count_cache(self) -> None:
52
+ """Invalidate the cached messages count."""
53
+ self._messages_count_cache = None
54
+
36
55
  # Internal: mapping for (de)serialization of conversation items
37
56
  _TypeMap: ClassVar[dict[str, type[BaseModel]]] = {
38
57
  # Messages
@@ -50,7 +69,7 @@ class Session(BaseModel):
50
69
  "AssistantMessageDelta": model.AssistantMessageDelta,
51
70
  "StartItem": model.StartItem,
52
71
  "StreamErrorItem": model.StreamErrorItem,
53
- "ResponseMetadataItem": model.ResponseMetadataItem,
72
+ "TaskMetadataItem": model.TaskMetadataItem,
54
73
  "InterruptItem": model.InterruptItem,
55
74
  }
56
75
 
@@ -109,7 +128,6 @@ class Session(BaseModel):
109
128
  loaded_memory = list(raw.get("loaded_memory", []))
110
129
  created_at = float(raw.get("created_at", time.time()))
111
130
  updated_at = float(raw.get("updated_at", created_at))
112
- messages_count = int(raw.get("messages_count", 0))
113
131
  model_name = raw.get("model_name")
114
132
 
115
133
  sess = Session(
@@ -121,7 +139,6 @@ class Session(BaseModel):
121
139
  loaded_memory=loaded_memory,
122
140
  created_at=created_at,
123
141
  updated_at=updated_at,
124
- messages_count=messages_count,
125
142
  model_name=model_name,
126
143
  )
127
144
 
@@ -154,10 +171,7 @@ class Session(BaseModel):
154
171
  # Best-effort load; skip malformed lines
155
172
  continue
156
173
  sess.conversation_history = history
157
- # Update messages count based on loaded history (only UserMessageItem and AssistantMessageItem)
158
- sess.messages_count = sum(
159
- 1 for it in history if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
160
- )
174
+ # messages_count is now a computed property, no need to set it
161
175
 
162
176
  return sess
163
177
 
@@ -190,10 +204,8 @@ class Session(BaseModel):
190
204
  def append_history(self, items: Sequence[model.ConversationItem]):
191
205
  # Append to in-memory history
192
206
  self.conversation_history.extend(items)
193
- # Update messages count (only UserMessageItem and AssistantMessageItem)
194
- self.messages_count += sum(
195
- 1 for it in items if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
196
- )
207
+ # Invalidate messages count cache
208
+ self._invalidate_messages_count_cache()
197
209
 
198
210
  # Incrementally persist to JSONL under messages directory
199
211
  messages_dir = self._messages_dir()
@@ -295,8 +307,8 @@ class Session(BaseModel):
295
307
  content=ri.content,
296
308
  session_id=self.id,
297
309
  )
298
- case model.ResponseMetadataItem() as mt:
299
- yield events.ResponseMetadataEvent(
310
+ case model.TaskMetadataItem() as mt:
311
+ yield events.TaskMetadataEvent(
300
312
  session_id=self.id,
301
313
  metadata=mt,
302
314
  )
@@ -309,6 +321,11 @@ class Session(BaseModel):
309
321
  session_id=self.id,
310
322
  item=dm,
311
323
  )
324
+ case model.StreamErrorItem() as se:
325
+ yield events.ErrorEvent(
326
+ error_message=se.error,
327
+ can_retry=False,
328
+ )
312
329
  case _:
313
330
  continue
314
331
  prev_item = it
@@ -21,7 +21,7 @@
21
21
  rel="stylesheet"
22
22
  />
23
23
  <link
24
- href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;700&display=swap"
24
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap"
25
25
  rel="stylesheet"
26
26
  />
27
27
  <style>
@@ -411,6 +411,9 @@
411
411
  font-size: var(--font-size-xs);
412
412
  color: var(--text-dim);
413
413
  border-left: 2px solid transparent;
414
+ display: flex;
415
+ flex-direction: column;
416
+ gap: 8px;
414
417
  }
415
418
  .metadata-line {
416
419
  display: flex;
@@ -22,11 +22,7 @@ def build_repl_status_snapshot(agent: "Agent | None", update_message: str | None
22
22
  tool_calls = 0
23
23
 
24
24
  if agent is not None:
25
- profile = agent.profile
26
- if profile is not None:
27
- model_name = profile.llm_client.model_name or ""
28
- else:
29
- model_name = "N/A"
25
+ model_name = agent.profile.llm_client.model_name or ""
30
26
 
31
27
  history = agent.session.conversation_history
32
28
  for item in history: