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.
- {overcode-0.3.5/src/overcode.egg-info → overcode-0.3.6}/PKG-INFO +1 -1
- {overcode-0.3.5 → overcode-0.3.6}/pyproject.toml +1 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/config.py +8 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/data_export.py +8 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon.py +10 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon_core.py +2 -33
- overcode-0.3.6/src/overcode/pricing.py +106 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/settings.py +2 -17
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/sister_poller.py +3 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_history.py +26 -11
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summarizer_client.py +15 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summarizer_component.py +17 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui.py +52 -3
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/daemon.py +23 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_logic.py +2 -2
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_render.py +36 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/daemon_status_bar.py +18 -15
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/status_timeline.py +1 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_api.py +4 -4
- {overcode-0.3.5 → overcode-0.3.6/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/SOURCES.txt +1 -0
- {overcode-0.3.5 → overcode-0.3.6}/LICENSE +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/MANIFEST.in +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/README.md +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/setup.cfg +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/claude_config.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/claude_pid.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/_shared.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/agent.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/budget.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/config.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/jobs.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/monitoring.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/perms.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/sister.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/skills.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/cli/split.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/dependency_check.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/duration.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/exceptions.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/follow_mode.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/history_reader.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/hook_handler.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/hook_status_detector.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/implementations.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/interfaces.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/job_launcher.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/job_manager.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/launcher.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/logging_config.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/mocks.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/monitor_daemon_state.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/notifier.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/pid_utils.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/presence_logger.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/protocols.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/session_manager.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/sister_controller.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/ssh_provisioner.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_constants.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_detector.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_detector_factory.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/status_patterns.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summary_columns.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/summary_groups.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/time_context.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tmux_manager.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tmux_utils.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui.tcss +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/input.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/session.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_actions/view.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/help_overlay.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/job_summary.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/session_summary.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/__init__.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_control_api.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_server.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode/web_templates.py +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.3.5 → overcode-0.3.6}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -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(
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
|
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
|
|
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.
|
|
1558
|
-
|
|
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:
|
|
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
|
|