overcode 0.3.5__tar.gz → 0.3.6__tar.gz

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 (122) hide show
  1. {overcode-0.3.5/src/overcode.egg-info → overcode-0.3.6}/PKG-INFO +1 -1
  2. {overcode-0.3.5 → overcode-0.3.6}/pyproject.toml +1 -1
  3. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/config.py +8 -0
  4. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/data_export.py +8 -1
  5. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon.py +10 -1
  6. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon_core.py +2 -33
  7. overcode-0.3.6/src/overcode/pricing.py +106 -0
  8. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/settings.py +2 -17
  9. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/sister_poller.py +3 -1
  10. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_history.py +26 -11
  11. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summarizer_client.py +15 -0
  12. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summarizer_component.py +17 -0
  13. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui.py +52 -3
  14. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/daemon.py +23 -0
  15. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_logic.py +2 -2
  16. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_render.py +36 -1
  17. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/daemon_status_bar.py +18 -15
  18. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/status_timeline.py +1 -1
  19. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_api.py +4 -4
  20. {overcode-0.3.5 → overcode-0.3.6/src/overcode.egg-info}/PKG-INFO +1 -1
  21. {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/SOURCES.txt +1 -0
  22. {overcode-0.3.5 → overcode-0.3.6}/LICENSE +0 -0
  23. {overcode-0.3.5 → overcode-0.3.6}/MANIFEST.in +0 -0
  24. {overcode-0.3.5 → overcode-0.3.6}/README.md +0 -0
  25. {overcode-0.3.5 → overcode-0.3.6}/setup.cfg +0 -0
  26. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/__init__.py +0 -0
  27. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/agent_scanner.py +0 -0
  28. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/bundled_skills.py +0 -0
  29. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/claude_config.py +0 -0
  30. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/claude_pid.py +0 -0
  31. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/__init__.py +0 -0
  32. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/__main__.py +0 -0
  33. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/_shared.py +0 -0
  34. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/agent.py +0 -0
  35. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/budget.py +0 -0
  36. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/config.py +0 -0
  37. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/daemon.py +0 -0
  38. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/hooks.py +0 -0
  39. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/jobs.py +0 -0
  40. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/monitoring.py +0 -0
  41. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/perms.py +0 -0
  42. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/sister.py +0 -0
  43. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/skills.py +0 -0
  44. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/split.py +0 -0
  45. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_claude_skill.md +0 -0
  46. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_logging.py +0 -0
  47. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_utils.py +0 -0
  48. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/dependency_check.py +0 -0
  49. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/duration.py +0 -0
  50. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/exceptions.py +0 -0
  51. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/follow_mode.py +0 -0
  52. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/history_reader.py +0 -0
  53. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/hook_handler.py +0 -0
  54. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/hook_status_detector.py +0 -0
  55. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/implementations.py +0 -0
  56. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/interfaces.py +0 -0
  57. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/job_launcher.py +0 -0
  58. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/job_manager.py +0 -0
  59. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/launcher.py +0 -0
  60. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/logging_config.py +0 -0
  61. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/mocks.py +0 -0
  62. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon_state.py +0 -0
  63. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/notifier.py +0 -0
  64. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/pid_utils.py +0 -0
  65. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/presence_logger.py +0 -0
  66. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/protocols.py +0 -0
  67. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/session_manager.py +0 -0
  68. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/sister_controller.py +0 -0
  69. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/ssh_provisioner.py +0 -0
  70. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/standing_instructions.py +0 -0
  71. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_constants.py +0 -0
  72. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_detector.py +0 -0
  73. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_detector_factory.py +0 -0
  74. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_patterns.py +0 -0
  75. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summary_columns.py +0 -0
  76. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summary_groups.py +0 -0
  77. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_daemon.py +0 -0
  78. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_daemon_core.py +0 -0
  79. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_layout.sh +0 -0
  80. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/__init__.py +0 -0
  81. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/renderer.py +0 -0
  82. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tmux_driver.py +0 -0
  83. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tui_eye.py +0 -0
  84. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tui_eye_skill.md +0 -0
  85. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/time_context.py +0 -0
  86. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tmux_manager.py +0 -0
  87. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tmux_utils.py +0 -0
  88. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui.tcss +0 -0
  89. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/__init__.py +0 -0
  90. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/input.py +0 -0
  91. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/navigation.py +0 -0
  92. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/session.py +0 -0
  93. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/view.py +0 -0
  94. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_helpers.py +0 -0
  95. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/__init__.py +0 -0
  96. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  97. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/command_bar.py +0 -0
  98. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  99. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  100. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/help_overlay.py +0 -0
  101. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  102. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/job_summary.py +0 -0
  103. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  104. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/preview_pane.py +0 -0
  105. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/session_summary.py +0 -0
  106. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  107. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  108. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
  109. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/usage_monitor.py +0 -0
  110. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/__init__.py +0 -0
  111. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/templates/analytics.html +0 -0
  112. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/templates/dashboard.html +0 -0
  113. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_chartjs.py +0 -0
  114. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_control_api.py +0 -0
  115. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_server.py +0 -0
  116. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_server_runner.py +0 -0
  117. {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_templates.py +0 -0
  118. {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/dependency_links.txt +0 -0
  119. {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/entry_points.txt +0 -0
  120. {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/requires.txt +0 -0
  121. {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/top_level.txt +0 -0
  122. {overcode-0.3.5 → overcode-0.3.6}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.3.5"
7
+ version = "0.3.6"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -129,12 +129,20 @@ def get_summarizer_config() -> dict:
129
129
  # Resolve the actual API key from the configured env var
130
130
  api_key = os.environ.get(api_key_var)
131
131
 
132
+ # Cost cap: default $100, configurable
133
+ cost_cap = _get_config_value("summarizer.cost_cap")
134
+ if cost_cap is None:
135
+ cost_cap = 100.0
136
+ else:
137
+ cost_cap = float(cost_cap)
138
+
132
139
  return {
133
140
  "api_type": api_type,
134
141
  "api_url": api_url,
135
142
  "model": model,
136
143
  "api_key": api_key,
137
144
  "api_key_var": api_key_var,
145
+ "cost_cap": cost_cap,
138
146
  }
139
147
 
140
148
 
@@ -8,6 +8,7 @@ from datetime import datetime
8
8
  from pathlib import Path
9
9
  from typing import Dict, Any
10
10
 
11
+ from .config import get_hostname
11
12
  from .session_manager import SessionManager
12
13
  from .status_history import read_agent_status_history
13
14
  from .presence_logger import read_presence_history
@@ -118,6 +119,7 @@ def _session_to_record(session, is_archived: bool) -> Dict[str, Any]:
118
119
  return {
119
120
  "id": session.id,
120
121
  "name": session.name,
122
+ "hostname": getattr(session, 'source_host', '') or get_hostname(),
121
123
  "tmux_session": session.tmux_session,
122
124
  "tmux_window": session.tmux_window,
123
125
  "start_directory": session.start_directory,
@@ -184,6 +186,7 @@ def _get_sessions_schema():
184
186
  return pa.schema([
185
187
  ("id", pa.string()),
186
188
  ("name", pa.string()),
189
+ ("hostname", pa.string()),
187
190
  ("start_time", pa.string()),
188
191
  ("end_time", pa.string()),
189
192
  ("is_archived", pa.bool_()),
@@ -201,6 +204,8 @@ def _get_timeline_schema():
201
204
  ("timestamp", pa.string()),
202
205
  ("agent", pa.string()),
203
206
  ("status", pa.string()),
207
+ ("session_id", pa.string()),
208
+ ("hostname", pa.string()),
204
209
  ])
205
210
 
206
211
 
@@ -223,11 +228,13 @@ def _build_timeline_records():
223
228
  records = []
224
229
  history = read_agent_status_history(hours=24.0)
225
230
 
226
- for ts, agent_name, status, activity in history:
231
+ for ts, agent_name, status, activity, session_id, hostname in history:
227
232
  records.append({
228
233
  "timestamp": ts.isoformat() if isinstance(ts, datetime) else str(ts),
229
234
  "agent": agent_name,
230
235
  "status": status,
236
+ "session_id": session_id,
237
+ "hostname": hostname,
231
238
  })
232
239
 
233
240
  return records
@@ -284,6 +284,10 @@ class MonitorDaemon:
284
284
  mode=detection_mode,
285
285
  )
286
286
 
287
+ # Hostname for history disambiguation
288
+ from .config import get_hostname
289
+ self._hostname = get_hostname()
290
+
287
291
  # Presence tracking (graceful degradation)
288
292
  self.presence = PresenceComponent(tmux_session=tmux_session)
289
293
 
@@ -1112,7 +1116,12 @@ class MonitorDaemon:
1112
1116
  session_states.append(session_state)
1113
1117
 
1114
1118
  # Log status history to session-specific file
1115
- log_agent_status(session.name, effective_status, activity, history_file=self.history_path)
1119
+ log_agent_status(
1120
+ session.name, effective_status, activity,
1121
+ history_file=self.history_path,
1122
+ session_id=session.id,
1123
+ hostname=self._hostname,
1124
+ )
1116
1125
 
1117
1126
  # Track if any session is not waiting for user
1118
1127
  if status != "waiting_user":
@@ -104,39 +104,8 @@ def calculate_time_accumulation(
104
104
  )
105
105
 
106
106
 
107
- def calculate_cost_estimate(
108
- input_tokens: int,
109
- output_tokens: int,
110
- cache_creation_tokens: int = 0,
111
- cache_read_tokens: int = 0,
112
- price_input: float = 3.0,
113
- price_output: float = 15.0,
114
- price_cache_write: float = 3.75,
115
- price_cache_read: float = 0.30,
116
- ) -> float:
117
- """Calculate estimated cost from token counts.
118
-
119
- Pure function - no side effects, fully testable.
120
-
121
- Args:
122
- input_tokens: Number of input tokens
123
- output_tokens: Number of output tokens
124
- cache_creation_tokens: Number of cache creation tokens
125
- cache_read_tokens: Number of cache read tokens
126
- price_input: Price per million input tokens (default: Sonnet)
127
- price_output: Price per million output tokens (default: Sonnet)
128
- price_cache_write: Price per million cache write tokens (default: Sonnet)
129
- price_cache_read: Price per million cache read tokens (default: Sonnet)
130
-
131
- Returns:
132
- Estimated cost in USD
133
- """
134
- return (
135
- (input_tokens / 1_000_000) * price_input +
136
- (output_tokens / 1_000_000) * price_output +
137
- (cache_creation_tokens / 1_000_000) * price_cache_write +
138
- (cache_read_tokens / 1_000_000) * price_cache_read
139
- )
107
+ # Re-exported from pricing module for backward compatibility
108
+ from .pricing import calculate_cost_estimate # noqa: F401
140
109
 
141
110
 
142
111
  def calculate_total_tokens(
@@ -0,0 +1,106 @@
1
+ """
2
+ Standalone pricing module for token cost estimation.
3
+
4
+ Provides model pricing tables and cost calculation for both Claude and
5
+ third-party models (OpenAI, etc.) used across the codebase — agent sessions
6
+ and the AI summariser.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class ModelPricing:
14
+ """Per-million-token pricing for a model."""
15
+ input: float
16
+ output: float
17
+ cache_write: float = 0.0
18
+ cache_read: float = 0.0
19
+
20
+
21
+ # Built-in pricing for known model families.
22
+ # Keys are checked as substrings against model names, so "opus" matches
23
+ # "claude-opus-4-6", "gpt-4o-mini" matches "gpt-4o-mini-2024-07-18", etc.
24
+ MODEL_PRICING: dict[str, ModelPricing] = {
25
+ # Claude models
26
+ "opus": ModelPricing(input=15.0, output=75.0, cache_write=18.75, cache_read=1.50),
27
+ "sonnet": ModelPricing(input=3.0, output=15.0, cache_write=3.75, cache_read=0.30),
28
+ "haiku": ModelPricing(input=0.80, output=4.0, cache_write=1.0, cache_read=0.08),
29
+ # OpenAI models (commonly used for summariser)
30
+ "gpt-4o-mini": ModelPricing(input=0.15, output=0.60),
31
+ "gpt-4o": ModelPricing(input=2.50, output=10.0),
32
+ "gpt-4-turbo": ModelPricing(input=10.0, output=30.0),
33
+ "gpt-3.5": ModelPricing(input=0.50, output=1.50),
34
+ }
35
+
36
+
37
+ def lookup_pricing(model: str) -> ModelPricing:
38
+ """Look up pricing for a model name by substring match.
39
+
40
+ Args:
41
+ model: Model name (e.g. "gpt-4o-mini", "claude-haiku-4-5-20250929")
42
+
43
+ Returns:
44
+ ModelPricing for the model family, or a zero-cost fallback if unknown.
45
+ """
46
+ model_lower = model.lower()
47
+ # Try longer keys first to avoid "gpt-4o" matching before "gpt-4o-mini"
48
+ for key in sorted(MODEL_PRICING, key=len, reverse=True):
49
+ if key in model_lower:
50
+ return MODEL_PRICING[key]
51
+ return ModelPricing(input=0.0, output=0.0)
52
+
53
+
54
+ def calculate_cost_estimate(
55
+ input_tokens: int,
56
+ output_tokens: int,
57
+ cache_creation_tokens: int = 0,
58
+ cache_read_tokens: int = 0,
59
+ price_input: float = 3.0,
60
+ price_output: float = 15.0,
61
+ price_cache_write: float = 3.75,
62
+ price_cache_read: float = 0.30,
63
+ ) -> float:
64
+ """Calculate estimated cost from token counts.
65
+
66
+ Pure function - no side effects, fully testable.
67
+
68
+ Args:
69
+ input_tokens: Number of input tokens
70
+ output_tokens: Number of output tokens
71
+ cache_creation_tokens: Number of cache creation tokens
72
+ cache_read_tokens: Number of cache read tokens
73
+ price_input: Price per million input tokens (default: Sonnet)
74
+ price_output: Price per million output tokens (default: Sonnet)
75
+ price_cache_write: Price per million cache write tokens (default: Sonnet)
76
+ price_cache_read: Price per million cache read tokens (default: Sonnet)
77
+
78
+ Returns:
79
+ Estimated cost in USD
80
+ """
81
+ return (
82
+ (input_tokens / 1_000_000) * price_input
83
+ + (output_tokens / 1_000_000) * price_output
84
+ + (cache_creation_tokens / 1_000_000) * price_cache_write
85
+ + (cache_read_tokens / 1_000_000) * price_cache_read
86
+ )
87
+
88
+
89
+ def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
90
+ """Convenience: estimate cost for a model from input/output tokens.
91
+
92
+ Args:
93
+ model: Model name (e.g. "gpt-4o-mini")
94
+ input_tokens: Number of input tokens
95
+ output_tokens: Number of output tokens
96
+
97
+ Returns:
98
+ Estimated cost in USD
99
+ """
100
+ pricing = lookup_pricing(model)
101
+ return calculate_cost_estimate(
102
+ input_tokens=input_tokens,
103
+ output_tokens=output_tokens,
104
+ price_input=pricing.input,
105
+ price_output=pricing.output,
106
+ )
@@ -205,23 +205,8 @@ TUI = TUISettings()
205
205
  # Config File Loading
206
206
  # =============================================================================
207
207
 
208
- @dataclass
209
- class ModelPricing:
210
- """Per-million-token pricing for a model."""
211
- input: float
212
- output: float
213
- cache_write: float
214
- cache_read: float
215
-
216
-
217
- # Built-in pricing for known Claude model families.
218
- # Keys are checked as prefixes against model names, so "opus" matches
219
- # "opus", "claude-opus-4-6", etc. Order matters: longer prefixes first.
220
- MODEL_PRICING: dict[str, ModelPricing] = {
221
- "opus": ModelPricing(input=15.0, output=75.0, cache_write=18.75, cache_read=1.50),
222
- "sonnet": ModelPricing(input=3.0, output=15.0, cache_write=3.75, cache_read=0.30),
223
- "haiku": ModelPricing(input=0.80, output=4.0, cache_write=1.0, cache_read=0.08),
224
- }
208
+ # Re-exported from pricing module for backward compatibility
209
+ from .pricing import ModelPricing, MODEL_PRICING # noqa: F401
225
210
 
226
211
 
227
212
  def get_model_pricing(model: str | None, fallback: "UserConfig") -> ModelPricing:
@@ -152,6 +152,7 @@ class SisterPoller:
152
152
  return {}
153
153
 
154
154
  result: Dict[str, List[Tuple[datetime, str]]] = {}
155
+ host = sister.name
155
156
  for agent_name, entries in data.get("agents", {}).items():
156
157
  pairs: List[Tuple[datetime, str]] = []
157
158
  for entry in entries:
@@ -161,7 +162,8 @@ class SisterPoller:
161
162
  except (KeyError, ValueError):
162
163
  continue
163
164
  if pairs:
164
- result[agent_name] = pairs
165
+ # Prefix with hostname for disambiguation across sisters
166
+ result[f"{host}/{agent_name}"] = pairs
165
167
  return result
166
168
 
167
169
  def _poll_sister(self, sister: SisterState) -> List[Session]:
@@ -17,7 +17,9 @@ def log_agent_status(
17
17
  agent_name: str,
18
18
  status: str,
19
19
  activity: str = "",
20
- history_file: Optional[Path] = None
20
+ history_file: Optional[Path] = None,
21
+ session_id: str = "",
22
+ hostname: str = "",
21
23
  ) -> None:
22
24
  """Log agent status to history CSV file.
23
25
 
@@ -29,6 +31,8 @@ def log_agent_status(
29
31
  status: Current status string
30
32
  activity: Optional activity description
31
33
  history_file: Optional path override (for testing)
34
+ session_id: Unique session ID (UUID) for disambiguation
35
+ hostname: Machine hostname for multi-host disambiguation
32
36
  """
33
37
  path = history_file or PATHS.agent_history
34
38
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -39,12 +43,14 @@ def log_agent_status(
39
43
  with open(path, 'a', newline='') as f:
40
44
  writer = csv.writer(f)
41
45
  if write_header:
42
- writer.writerow(['timestamp', 'agent', 'status', 'activity'])
46
+ writer.writerow(['timestamp', 'agent', 'status', 'activity', 'session_id', 'hostname'])
43
47
  writer.writerow([
44
48
  datetime.now().isoformat(),
45
49
  agent_name,
46
50
  status,
47
- activity[:100] if activity else ""
51
+ activity[:100] if activity else "",
52
+ session_id,
53
+ hostname,
48
54
  ])
49
55
 
50
56
 
@@ -63,7 +69,7 @@ class StatusHistoryFile:
63
69
  self._lock = threading.Lock()
64
70
  self._cached_mtime: float = 0.0
65
71
  self._cached_size: int = 0
66
- self._cached_entries: List[Tuple[datetime, str, str, str]] = []
72
+ self._cached_entries: List[Tuple[datetime, str, str, str, str, str]] = []
67
73
  self._cached_hours: float = 0.0
68
74
  self._read_offset: int = 0
69
75
 
@@ -71,7 +77,7 @@ class StatusHistoryFile:
71
77
  self,
72
78
  hours: float = 3.0,
73
79
  agent_name: Optional[str] = None,
74
- ) -> List[Tuple[datetime, str, str, str]]:
80
+ ) -> List[Tuple[datetime, str, str, str, str, str]]:
75
81
  """Read status history entries, using cache when possible."""
76
82
  try:
77
83
  stat = self._path.stat()
@@ -177,11 +183,11 @@ class StatusHistoryFile:
177
183
  return lo
178
184
 
179
185
  @staticmethod
180
- def _parse_rows(f, start_offset: int) -> List[Tuple[datetime, str, str, str]]:
186
+ def _parse_rows(f, start_offset: int) -> List[Tuple[datetime, str, str, str, str, str]]:
181
187
  """Parse CSV rows from start_offset to end of file."""
182
188
  f.seek(start_offset)
183
189
  data = f.read().decode('utf-8', errors='replace')
184
- entries: List[Tuple[datetime, str, str, str]] = []
190
+ entries: List[Tuple[datetime, str, str, str, str, str]] = []
185
191
  for row in csv.reader(data.splitlines()):
186
192
  if len(row) < 3:
187
193
  continue
@@ -189,7 +195,14 @@ class StatusHistoryFile:
189
195
  continue
190
196
  try:
191
197
  ts = datetime.fromisoformat(row[0])
192
- entries.append((ts, row[1], row[2], row[3] if len(row) > 3 else ''))
198
+ entries.append((
199
+ ts,
200
+ row[1], # agent
201
+ row[2], # status
202
+ row[3] if len(row) > 3 else '', # activity
203
+ row[4] if len(row) > 4 else '', # session_id
204
+ row[5] if len(row) > 5 else '', # hostname
205
+ ))
193
206
  except (ValueError, IndexError):
194
207
  continue
195
208
  return entries
@@ -222,7 +235,7 @@ def read_agent_status_history(
222
235
  hours: float = 3.0,
223
236
  agent_name: Optional[str] = None,
224
237
  history_file: Optional[Path] = None
225
- ) -> List[Tuple[datetime, str, str, str]]:
238
+ ) -> List[Tuple[datetime, str, str, str, str, str]]:
226
239
  """Read agent status history from CSV file.
227
240
 
228
241
  Args:
@@ -231,7 +244,9 @@ def read_agent_status_history(
231
244
  history_file: Optional path override (for testing)
232
245
 
233
246
  Returns:
234
- List of (timestamp, agent, status, activity) tuples, oldest first
247
+ List of (timestamp, agent, status, activity, session_id, hostname)
248
+ tuples, oldest first. session_id and hostname may be empty for
249
+ rows written before v0.3.6.
235
250
  """
236
251
  path = history_file or PATHS.agent_history
237
252
  return _get_or_create_reader(path).read(hours, agent_name)
@@ -253,7 +268,7 @@ def get_agent_timeline(
253
268
  List of (timestamp, status) tuples for the agent
254
269
  """
255
270
  history = read_agent_status_history(hours, agent_name, history_file)
256
- return [(ts, status) for ts, _, status, _ in history]
271
+ return [(ts, status) for ts, _, status, _, _, _ in history]
257
272
 
258
273
 
259
274
  def clear_old_history(
@@ -102,6 +102,9 @@ class SummarizerClient:
102
102
  self.api_key = api_key or config["api_key"]
103
103
  self.api_type = config.get("api_type", "openai")
104
104
  self._available = bool(self.api_key)
105
+ # Token usage from the most recent API call (for cost tracking)
106
+ self.last_input_tokens: int = 0
107
+ self.last_output_tokens: int = 0
105
108
 
106
109
  @property
107
110
  def available(self) -> bool:
@@ -150,6 +153,9 @@ class SummarizerClient:
150
153
 
151
154
  def _call_anthropic(self, prompt: str, max_tokens: int) -> Optional[str]:
152
155
  """Call the Anthropic Messages API."""
156
+ self.last_input_tokens = 0
157
+ self.last_output_tokens = 0
158
+
153
159
  payload = json.dumps({
154
160
  "model": self.model,
155
161
  "max_tokens": max_tokens,
@@ -172,6 +178,9 @@ class SummarizerClient:
172
178
  if response.status == 200:
173
179
  result = json.loads(response.read().decode("utf-8"))
174
180
  content = result["content"][0]["text"]
181
+ usage = result.get("usage", {})
182
+ self.last_input_tokens = usage.get("input_tokens", 0)
183
+ self.last_output_tokens = usage.get("output_tokens", 0)
175
184
  return content.strip()
176
185
  else:
177
186
  logger.warning(f"Summarizer API error: {response.status}")
@@ -188,6 +197,9 @@ class SummarizerClient:
188
197
 
189
198
  def _call_openai(self, prompt: str, max_tokens: int) -> Optional[str]:
190
199
  """Call the OpenAI Chat Completions API."""
200
+ self.last_input_tokens = 0
201
+ self.last_output_tokens = 0
202
+
191
203
  payload = json.dumps({
192
204
  "model": self.model,
193
205
  "max_tokens": max_tokens,
@@ -210,6 +222,9 @@ class SummarizerClient:
210
222
  if response.status == 200:
211
223
  result = json.loads(response.read().decode("utf-8"))
212
224
  content = result["choices"][0]["message"]["content"]
225
+ usage = result.get("usage", {})
226
+ self.last_input_tokens = usage.get("prompt_tokens", 0)
227
+ self.last_output_tokens = usage.get("completion_tokens", 0)
213
228
  return content.strip()
214
229
  else:
215
230
  logger.warning(
@@ -46,6 +46,8 @@ class SummarizerConfig:
46
46
  context_interval: float = 15.0 # Seconds between context summary updates (less frequent)
47
47
  lines: int = DEFAULT_CAPTURE_LINES # Pane lines to capture
48
48
  max_tokens: int = 150 # Max response tokens
49
+ idle_timeout: float = 300.0 # Auto-disable after 5 minutes of no TUI keypresses
50
+ cost_cap: float = 100.0 # Per-TUI-launch cost cap in USD; requires restart to reset
49
51
 
50
52
 
51
53
  class SummarizerComponent:
@@ -95,6 +97,8 @@ class SummarizerComponent:
95
97
  # Stats
96
98
  self.total_calls = 0
97
99
  self.total_tokens = 0
100
+ self.total_cost_usd: float = 0.0
101
+ self.cost_cap_hit: bool = False
98
102
 
99
103
  @property
100
104
  def available(self) -> bool:
@@ -212,6 +216,7 @@ class SummarizerComponent:
212
216
  )
213
217
 
214
218
  self.total_calls += 1
219
+ self._accumulate_cost()
215
220
 
216
221
  if result and result.strip().upper() != "UNCHANGED":
217
222
  summary.text = result.strip()
@@ -238,6 +243,7 @@ class SummarizerComponent:
238
243
  )
239
244
 
240
245
  self.total_calls += 1
246
+ self._accumulate_cost()
241
247
 
242
248
  if result and result.strip().upper() != "UNCHANGED":
243
249
  summary.context = result.strip()
@@ -249,6 +255,17 @@ class SummarizerComponent:
249
255
  except Exception as e:
250
256
  logger.warning(f"Context summary error for {session.name}: {e}")
251
257
 
258
+ def _accumulate_cost(self) -> None:
259
+ """Accumulate cost from the most recent API call."""
260
+ if not self._client:
261
+ return
262
+ input_tokens = getattr(self._client, 'last_input_tokens', 0)
263
+ output_tokens = getattr(self._client, 'last_output_tokens', 0)
264
+ model = getattr(self._client, 'model', None)
265
+ if (input_tokens or output_tokens) and isinstance(model, str):
266
+ from .pricing import estimate_cost
267
+ self.total_cost_usd += estimate_cost(model, input_tokens, output_tokens)
268
+
252
269
  def _capture_pane(self, window: str) -> Optional[str]:
253
270
  """Capture pane content for summarization.
254
271
 
@@ -341,9 +341,11 @@ class SupervisorTUI(
341
341
  self._usage_monitor = UsageMonitor()
342
342
 
343
343
  # AI Summarizer - owned by TUI, not daemon (zero cost when TUI closed)
344
+ from .config import get_summarizer_config as _get_sum_cfg
345
+ _sum_cfg = _get_sum_cfg()
344
346
  self._summarizer = SummarizerComponent(
345
347
  tmux_session=tmux_session,
346
- config=SummarizerConfig(enabled=False), # Disabled by default
348
+ config=SummarizerConfig(enabled=False, cost_cap=_sum_cfg.get("cost_cap", 100.0)),
347
349
  )
348
350
  self._summaries: dict[str, AgentSummary] = {}
349
351
 
@@ -1554,12 +1556,45 @@ class SupervisorTUI(
1554
1556
  def _update_summaries_async(self) -> None:
1555
1557
  """Background thread for AI summarization.
1556
1558
 
1557
- Only runs if summarizer is enabled. Updates are applied to widgets
1558
- via call_from_thread.
1559
+ Only runs if summarizer is enabled. Auto-pauses after idle timeout
1560
+ (no TUI keypresses) to prevent runaway API costs.
1559
1561
  """
1560
1562
  if not self._summarizer.enabled:
1561
1563
  return
1562
1564
 
1565
+ # Auto-pause if TUI has been idle beyond the configured timeout
1566
+ if self._last_keypress > 0:
1567
+ idle_secs = time.monotonic() - self._last_keypress
1568
+ if idle_secs >= self._summarizer.config.idle_timeout:
1569
+ self._summarizer_idle_paused = True
1570
+ self._summarizer.config.enabled = False
1571
+ if self._summarizer._client:
1572
+ self._summarizer._client.close()
1573
+ self._summarizer._client = None
1574
+ idle_mins = int(idle_secs // 60)
1575
+ self.call_from_thread(
1576
+ self.notify,
1577
+ f"AI Summarizer paused ({idle_mins}m idle — press any key to resume)",
1578
+ severity="warning",
1579
+ )
1580
+ return
1581
+
1582
+ # Hard-halt if cost cap exceeded (requires TUI restart to reset)
1583
+ cap = self._summarizer.config.cost_cap
1584
+ if cap > 0 and self._summarizer.total_cost_usd >= cap:
1585
+ self._summarizer.cost_cap_hit = True
1586
+ self._summarizer.config.enabled = False
1587
+ if self._summarizer._client:
1588
+ self._summarizer._client.close()
1589
+ self._summarizer._client = None
1590
+ from .tui_helpers import format_cost
1591
+ self.call_from_thread(
1592
+ self.notify,
1593
+ f"AI Summarizer HALTED — cost cap {format_cost(cap)} reached. Restart TUI to reset.",
1594
+ severity="error",
1595
+ )
1596
+ return
1597
+
1563
1598
  # Get fresh session list (filtered to this tmux session)
1564
1599
  all_sessions = self.session_manager.list_sessions()
1565
1600
  sessions = [s for s in all_sessions if s.tmux_session == self.tmux_session]
@@ -3402,6 +3437,9 @@ class SupervisorTUI(
3402
3437
 
3403
3438
  # Throttle TUI heartbeat writes to once per 5 seconds
3404
3439
  _last_heartbeat_write: float = 0.0
3440
+ # Track last keypress for summariser idle auto-shutoff
3441
+ _last_keypress: float = 0.0
3442
+ _summarizer_idle_paused: bool = False
3405
3443
 
3406
3444
  def on_key(self, event: events.Key) -> None:
3407
3445
  """Signal activity to daemon on any keypress."""
@@ -3409,10 +3447,21 @@ class SupervisorTUI(
3409
3447
 
3410
3448
  # Write TUI heartbeat (throttled to every 5s)
3411
3449
  now = time.monotonic()
3450
+ self._last_keypress = now
3412
3451
  if now - self._last_heartbeat_write >= 5.0:
3413
3452
  self._last_heartbeat_write = now
3414
3453
  write_tui_heartbeat(self.tmux_session)
3415
3454
 
3455
+ # Re-enable summariser if it was auto-paused due to idle (not if cost cap hit)
3456
+ if self._summarizer_idle_paused and not self._summarizer.cost_cap_hit:
3457
+ self._summarizer_idle_paused = False
3458
+ self._summarizer.config.enabled = True
3459
+ if not self._summarizer._client:
3460
+ from .summarizer_client import SummarizerClient
3461
+ self._summarizer._client = SummarizerClient()
3462
+ self.notify("AI Summarizer resumed (activity detected)", severity="information")
3463
+ self._update_summaries_async()
3464
+
3416
3465
  # Auto-recover if focus was lost or landed on a non-interactive widget
3417
3466
  # (e.g., clicking the terminal window focuses the preview pane)
3418
3467
  if self._should_recover_focus():
@@ -114,6 +114,29 @@ class DaemonActionsMixin:
114
114
  self.notify("AI Summarizer unavailable - set OPENAI_API_KEY", severity="warning")
115
115
  return
116
116
 
117
+ # Block re-enable if cost cap was hit (requires TUI restart)
118
+ if self._summarizer.cost_cap_hit and not self._summarizer.config.enabled:
119
+ from ..tui_helpers import format_cost
120
+ cap = self._summarizer.config.cost_cap
121
+ self.notify(
122
+ f"AI Summarizer HALTED — cost cap {format_cost(cap)} reached. Restart TUI to reset.",
123
+ severity="error",
124
+ )
125
+ return
126
+
127
+ # Block enable if model has no pricing data
128
+ if not self._summarizer.config.enabled:
129
+ from ..pricing import lookup_pricing
130
+ from ..config import get_summarizer_config
131
+ model = get_summarizer_config()["model"]
132
+ pricing = lookup_pricing(model)
133
+ if pricing.input == 0.0 and pricing.output == 0.0:
134
+ self.notify(
135
+ f"AI Summarizer blocked — no cost data for model '{model}'",
136
+ severity="error",
137
+ )
138
+ return
139
+
117
140
  # Toggle the state
118
141
  self._summarizer.config.enabled = not self._summarizer.config.enabled
119
142
 
@@ -424,7 +424,7 @@ def calculate_spin_stats(
424
424
 
425
425
 
426
426
  def calculate_mean_spin_from_history(
427
- history: List[Tuple[datetime, str, str, str]],
427
+ history: list,
428
428
  agent_names: List[str],
429
429
  baseline_minutes: int,
430
430
  now: Optional[datetime] = None,
@@ -456,7 +456,7 @@ def calculate_mean_spin_from_history(
456
456
  # Filter to window and active agents only
457
457
  window_history = [
458
458
  (ts, agent, status)
459
- for ts, agent, status, _ in history
459
+ for ts, agent, status, *_ in history
460
460
  if cutoff <= ts <= now and agent in agent_names
461
461
  ]
462
462