overcode 0.5.3__tar.gz → 0.5.4__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.5.3/src/overcode.egg-info → overcode-0.5.4}/PKG-INFO +1 -1
- {overcode-0.5.3 → overcode-0.5.4}/pyproject.toml +2 -2
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_stats.py +89 -43
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode_stats.py +176 -32
- overcode-0.5.4/src/overcode/entrypoint.py +40 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/launcher.py +50 -5
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/session_manager.py +71 -1
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tmux_manager.py +9 -2
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui.py +102 -17
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_logic.py +68 -0
- {overcode-0.5.3 → overcode-0.5.4/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/SOURCES.txt +1 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/entry_points.txt +1 -1
- {overcode-0.5.3 → overcode-0.5.4}/LICENSE +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/MANIFEST.in +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/README.md +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/setup.cfg +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/base.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/claude_code.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/codex.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/codex_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/grok.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/grok_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/hermes.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/hermes_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_patterns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_plugin_install.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/claude_config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/claude_pid.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/_shared.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/agent.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/budget.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/doctor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/focal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/jobs.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/models.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/monitoring.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/parallelism.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/perms.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/sister.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/skills.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/split.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/tags.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/wrappers.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/data/model_metadata.json +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/data_export.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/dependency_check.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/doctor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/duration.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/exceptions.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/follow_mode.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hermes_plugin/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hermes_plugin/plugin.yaml +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/history_reader.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hook_handler.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hook_status_detector.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/implementations.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/interfaces.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/job_launcher.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/job_manager.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/logging_config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/mocks.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/model_metadata.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon_core.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon_state.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/notifier.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/index.js +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/tui.js +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry.js +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/pid_utils.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/presence_logger.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/pricing.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/process_resources.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/protocols.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sandbox_detect.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/settings.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sister_controller.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sister_poller.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/ssh_provisioner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/stats_reader.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_constants.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_detector.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_detector_factory.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_history.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_patterns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summary_columns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summary_groups.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/time_context.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tmux_utils.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui.tcss +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/input.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/session.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/view.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_render.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/help_overlay.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/job_summary.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/jump_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/modal_base.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/new_agent_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/passthru_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/session_summary.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/status_timeline.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/tmux_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_api.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_control_api.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_server.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_templates.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode/wrapper.py +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.4}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "overcode"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.4"
|
|
8
8
|
description = "A supervisor for managing multiple Claude Code instances in tmux"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Mike Bond"}
|
|
@@ -61,7 +61,7 @@ export = [
|
|
|
61
61
|
]
|
|
62
62
|
|
|
63
63
|
[project.scripts]
|
|
64
|
-
overcode = "overcode.
|
|
64
|
+
overcode = "overcode.entrypoint:main"
|
|
65
65
|
tui-eye = "overcode.testing.tui_eye:main"
|
|
66
66
|
|
|
67
67
|
[tool.setuptools.packages.find]
|
|
@@ -60,7 +60,9 @@ from ..stats_reader import (
|
|
|
60
60
|
from .opencode_stats import (
|
|
61
61
|
_as_float,
|
|
62
62
|
_as_int,
|
|
63
|
+
_cached_records,
|
|
63
64
|
_launch_ms,
|
|
65
|
+
_optional_ms,
|
|
64
66
|
_parse_model,
|
|
65
67
|
_placeholders,
|
|
66
68
|
_table_columns,
|
|
@@ -216,6 +218,69 @@ def _row_identities(rows: Sequence[sqlite3.Row]) -> Tuple[Optional[str], Optiona
|
|
|
216
218
|
return _parse_model(active["model"]), (active["agent"] or None)
|
|
217
219
|
|
|
218
220
|
|
|
221
|
+
def _scan_sql(ids: Sequence[str]) -> Tuple[str, List[Any]]:
|
|
222
|
+
"""The newest ``_MESSAGE_SCAN_LIMIT`` user/assistant rows of each
|
|
223
|
+
conversation — narrow columns only, unordered across conversations.
|
|
224
|
+
|
|
225
|
+
One bounded probe per conversation rather than one ``session_id IN
|
|
226
|
+
(...) ORDER BY ... LIMIT`` over all of them — the shared sort's cost
|
|
227
|
+
grew roughly quadratically with the number of owned ids (#476; the v1
|
|
228
|
+
reader's ``_scan_sql`` has the numbers). Inside one conversation
|
|
229
|
+
``seq`` is assigned in insertion order, so ``ORDER BY seq DESC`` on the
|
|
230
|
+
unique ``(session_id, seq)`` index yields exactly the newest rows
|
|
231
|
+
without a sort; the caller merges by ``(time_created, seq)`` in Python.
|
|
232
|
+
|
|
233
|
+
``data`` is deliberately not selected: on a real store an assistant
|
|
234
|
+
row carries its tool output inline (1 KB-1 MB); bodies are fetched
|
|
235
|
+
once per version through ``opencode_stats._cached_records``.
|
|
236
|
+
"""
|
|
237
|
+
probe = (
|
|
238
|
+
"SELECT * FROM (SELECT id, session_id, type, seq, time_created, "
|
|
239
|
+
"time_updated FROM session_message WHERE session_id = ? "
|
|
240
|
+
"AND type IN ('user', 'assistant') ORDER BY seq DESC LIMIT ?)"
|
|
241
|
+
)
|
|
242
|
+
sql = " UNION ALL ".join([probe] * len(ids))
|
|
243
|
+
params: List[Any] = []
|
|
244
|
+
for sid in ids:
|
|
245
|
+
params.extend((sid, _MESSAGE_SCAN_LIMIT))
|
|
246
|
+
return sql, params
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_session_message_record(
|
|
250
|
+
data: Optional[str], mtype: Any
|
|
251
|
+
) -> Tuple[Optional[Dict[str, Any]], bool]:
|
|
252
|
+
"""Extract what the scan needs from a v2 ``session_message.data`` body.
|
|
253
|
+
|
|
254
|
+
``mtype`` is the row's ``type`` column (the envelope has no role).
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
envelope = json.loads(data) if data is not None else None
|
|
258
|
+
except (ValueError, TypeError):
|
|
259
|
+
return None, False
|
|
260
|
+
if not isinstance(envelope, dict):
|
|
261
|
+
return None, False
|
|
262
|
+
tokens = envelope.get("tokens")
|
|
263
|
+
tokens = tokens if isinstance(tokens, dict) else {}
|
|
264
|
+
cache = tokens.get("cache")
|
|
265
|
+
cache = cache if isinstance(cache, dict) else {}
|
|
266
|
+
times = envelope.get("time")
|
|
267
|
+
times = times if isinstance(times, dict) else {}
|
|
268
|
+
agent = envelope.get("agent")
|
|
269
|
+
record = {
|
|
270
|
+
"input": _as_int(tokens.get("input")),
|
|
271
|
+
"output": _as_int(tokens.get("output")),
|
|
272
|
+
"reasoning": _as_int(tokens.get("reasoning")),
|
|
273
|
+
"cache_read": _as_int(cache.get("read")),
|
|
274
|
+
"cache_write": _as_int(cache.get("write")),
|
|
275
|
+
"created": _optional_ms(times.get("created")),
|
|
276
|
+
"completed": _optional_ms(times.get("completed")),
|
|
277
|
+
"model": _parse_model(envelope.get("model")),
|
|
278
|
+
"agent": agent if isinstance(agent, str) and agent else None,
|
|
279
|
+
}
|
|
280
|
+
in_flight = mtype == "assistant" and record["completed"] is None
|
|
281
|
+
return record, not in_flight
|
|
282
|
+
|
|
283
|
+
|
|
219
284
|
def _scan_messages(
|
|
220
285
|
conn: sqlite3.Connection,
|
|
221
286
|
session_ids: Sequence[str],
|
|
@@ -246,24 +311,22 @@ def _scan_messages(
|
|
|
246
311
|
if not ids:
|
|
247
312
|
return out
|
|
248
313
|
|
|
249
|
-
sql = (
|
|
250
|
-
"SELECT id, session_id, type, time_created, data FROM session_message "
|
|
251
|
-
f"WHERE session_id IN ({_placeholders(len(ids))}) "
|
|
252
|
-
"AND type IN ('user', 'assistant') "
|
|
253
|
-
"ORDER BY time_created DESC, seq DESC LIMIT ?"
|
|
254
|
-
)
|
|
314
|
+
sql, params = _scan_sql(ids)
|
|
255
315
|
try:
|
|
256
|
-
rows = conn.execute(sql,
|
|
316
|
+
rows = conn.execute(sql, params).fetchall()
|
|
317
|
+
rows.sort(key=lambda row: (row[4], row[3]), reverse=True)
|
|
318
|
+
records = _cached_records(
|
|
319
|
+
conn, "session_message",
|
|
320
|
+
[(row[0], row[5], row[2]) for row in rows],
|
|
321
|
+
_parse_session_message_record,
|
|
322
|
+
)
|
|
257
323
|
except sqlite3.Error:
|
|
258
324
|
return out
|
|
259
325
|
|
|
260
326
|
seen_context = False
|
|
261
|
-
for
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
except (ValueError, TypeError):
|
|
265
|
-
continue
|
|
266
|
-
if not isinstance(envelope, dict):
|
|
327
|
+
for msg_id, session_id, mtype, _seq, time_created, _updated in rows:
|
|
328
|
+
record = records.get(msg_id)
|
|
329
|
+
if record is None:
|
|
267
330
|
continue
|
|
268
331
|
if mtype == "user":
|
|
269
332
|
out["interaction_count"] += 1
|
|
@@ -271,22 +334,13 @@ def _scan_messages(
|
|
|
271
334
|
if mtype != "assistant":
|
|
272
335
|
continue
|
|
273
336
|
|
|
274
|
-
tokens = envelope.get("tokens")
|
|
275
|
-
tokens = tokens if isinstance(tokens, dict) else {}
|
|
276
|
-
cache = tokens.get("cache")
|
|
277
|
-
cache = cache if isinstance(cache, dict) else {}
|
|
278
|
-
|
|
279
337
|
# v2's assistant data has no `tokens.total` (verified); the context
|
|
280
338
|
# size is the processed prompt — input + cache read + cache write —
|
|
281
339
|
# the semantics AgentSessionStats.current_context_tokens documents.
|
|
282
340
|
# Rows arrive newest-first, and only the active conversation's
|
|
283
341
|
# context is meaningful — an older /new session's is stale.
|
|
284
342
|
if not seen_context and (active_id is None or session_id == active_id):
|
|
285
|
-
context =
|
|
286
|
-
_as_int(tokens.get("input"))
|
|
287
|
-
+ _as_int(cache.get("read"))
|
|
288
|
-
+ _as_int(cache.get("write"))
|
|
289
|
-
)
|
|
343
|
+
context = record["input"] + record["cache_read"] + record["cache_write"]
|
|
290
344
|
if context > 0:
|
|
291
345
|
out["current_context_tokens"] = context
|
|
292
346
|
seen_context = True
|
|
@@ -296,32 +350,24 @@ def _scan_messages(
|
|
|
296
350
|
# tracked session's identities must not leak into the active
|
|
297
351
|
# conversation.
|
|
298
352
|
if active_id is not None and session_id == active_id:
|
|
299
|
-
if out["model"] is None:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
times = envelope.get("time")
|
|
309
|
-
times = times if isinstance(times, dict) else {}
|
|
310
|
-
created = times.get("created")
|
|
311
|
-
completed = times.get("completed")
|
|
312
|
-
if isinstance(created, (int, float)) and isinstance(completed, (int, float)):
|
|
353
|
+
if out["model"] is None and record["model"]:
|
|
354
|
+
out["model"] = record["model"]
|
|
355
|
+
if out["agent"] is None and record["agent"]:
|
|
356
|
+
out["agent"] = record["agent"]
|
|
357
|
+
|
|
358
|
+
created = record["created"]
|
|
359
|
+
completed = record["completed"]
|
|
360
|
+
if created is not None and completed is not None:
|
|
313
361
|
elapsed = (completed - created) / 1000.0
|
|
314
362
|
if elapsed > 0:
|
|
315
363
|
out["work_times"].append(elapsed)
|
|
316
364
|
|
|
317
365
|
if since_ms is not None and _as_int(time_created) >= since_ms:
|
|
318
366
|
window = out["window"]
|
|
319
|
-
window["input_tokens"] +=
|
|
320
|
-
window["output_tokens"] +=
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
window["cache_creation_tokens"] += _as_int(cache.get("write"))
|
|
324
|
-
window["cache_read_tokens"] += _as_int(cache.get("read"))
|
|
367
|
+
window["input_tokens"] += record["input"]
|
|
368
|
+
window["output_tokens"] += record["output"] + record["reasoning"]
|
|
369
|
+
window["cache_creation_tokens"] += record["cache_write"]
|
|
370
|
+
window["cache_read_tokens"] += record["cache_read"]
|
|
325
371
|
|
|
326
372
|
out["work_times"].reverse()
|
|
327
373
|
return out
|
|
@@ -26,9 +26,10 @@ daemon tick.
|
|
|
26
26
|
import json
|
|
27
27
|
import os
|
|
28
28
|
import sqlite3
|
|
29
|
+
import threading
|
|
29
30
|
from datetime import datetime
|
|
30
31
|
from pathlib import Path
|
|
31
|
-
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
32
33
|
|
|
33
34
|
from .. import model_metadata
|
|
34
35
|
from ..stats_reader import (
|
|
@@ -323,6 +324,159 @@ def fetch_rows_for_directory(
|
|
|
323
324
|
return conn.execute(sql, (*candidates, since_ms)).fetchall()
|
|
324
325
|
|
|
325
326
|
|
|
327
|
+
def _scan_sql(ids: Sequence[str]) -> Tuple[str, List[Any]]:
|
|
328
|
+
"""The newest ``_MESSAGE_SCAN_LIMIT`` rows of each conversation — narrow
|
|
329
|
+
columns only, unordered across conversations.
|
|
330
|
+
|
|
331
|
+
One bounded probe per conversation, not one ``WHERE session_id IN (...)
|
|
332
|
+
ORDER BY ... LIMIT`` over all of them: SQLite answers the latter through
|
|
333
|
+
a temp B-tree fed by every candidate row, so its cost grew roughly
|
|
334
|
+
quadratically with the number of owned ids (~2 ms at one, ~115 ms at
|
|
335
|
+
eight on a real store — per agent, per second, for the burn rate; #476).
|
|
336
|
+
Each probe here walks the ``(session_id, time_created)`` index backwards
|
|
337
|
+
and stops at its own limit, which is also what the per-session scan
|
|
338
|
+
limit documents. The caller merges in Python: an SQL ``ORDER BY`` over
|
|
339
|
+
the union would materialise every row again.
|
|
340
|
+
|
|
341
|
+
``data`` is deliberately not selected — see ``_cached_records``.
|
|
342
|
+
"""
|
|
343
|
+
probe = (
|
|
344
|
+
"SELECT * FROM (SELECT id, session_id, time_created, time_updated "
|
|
345
|
+
"FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT ?)"
|
|
346
|
+
)
|
|
347
|
+
sql = " UNION ALL ".join([probe] * len(ids))
|
|
348
|
+
params: List[Any] = []
|
|
349
|
+
for sid in ids:
|
|
350
|
+
params.extend((sid, _MESSAGE_SCAN_LIMIT))
|
|
351
|
+
return sql, params
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# Per-row parse cache (#476). The TUI re-reads a conversation's newest rows
|
|
355
|
+
# every second for the burn rate and every 5 s for the stats columns, and
|
|
356
|
+
# on a real store an assistant row is 1 KB-1 MB of JSON (tool output
|
|
357
|
+
# travels inline). So the scan selects only the narrow columns — they all
|
|
358
|
+
# sit in the row's first page, so it never touches overflow pages — and
|
|
359
|
+
# fetches + parses ``data`` only for rows it has not seen at that
|
|
360
|
+
# ``time_updated``. opencode rewrites ``time_updated`` whenever it rewrites
|
|
361
|
+
# a row (on a live store an assistant row's ``time_updated`` is its
|
|
362
|
+
# ``time.completed``); an assistant row still in flight (no
|
|
363
|
+
# ``time.completed`` yet) is never cached, so a stale key cannot freeze it.
|
|
364
|
+
# Entries are small extracted records, never the envelope.
|
|
365
|
+
_ROW_CACHE_MAX = 50_000
|
|
366
|
+
_row_cache: Dict[Tuple[str, str, str], Tuple[Any, Any]] = {}
|
|
367
|
+
_row_cache_lock = threading.Lock()
|
|
368
|
+
|
|
369
|
+
# A parsed row: the record the scan consumes, and whether it may be cached.
|
|
370
|
+
RowParser = Callable[[Optional[str], Any], Tuple[Optional[Dict[str, Any]], bool]]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def clear_row_cache() -> None:
|
|
374
|
+
"""Drop every cached row record (tests)."""
|
|
375
|
+
with _row_cache_lock:
|
|
376
|
+
_row_cache.clear()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _database_key(conn: sqlite3.Connection) -> str:
|
|
380
|
+
"""The file behind ``conn`` — cache entries must not cross stores."""
|
|
381
|
+
try:
|
|
382
|
+
for _seq, name, file in conn.execute("PRAGMA database_list"):
|
|
383
|
+
if name == "main":
|
|
384
|
+
return file or ""
|
|
385
|
+
except sqlite3.Error:
|
|
386
|
+
pass
|
|
387
|
+
return ""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _fetch_data(
|
|
391
|
+
conn: sqlite3.Connection, table: str, ids: Sequence[str]
|
|
392
|
+
) -> Dict[str, str]:
|
|
393
|
+
"""``data`` for the given row ids, by primary key."""
|
|
394
|
+
out: Dict[str, str] = {}
|
|
395
|
+
for start in range(0, len(ids), 500):
|
|
396
|
+
chunk = list(ids[start : start + 500])
|
|
397
|
+
sql = f"SELECT id, data FROM {table} WHERE id IN ({_placeholders(len(chunk))})"
|
|
398
|
+
for msg_id, data in conn.execute(sql, chunk):
|
|
399
|
+
out[msg_id] = data
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cached_records(
|
|
404
|
+
conn: sqlite3.Connection,
|
|
405
|
+
table: str,
|
|
406
|
+
rows: Sequence[Tuple[str, Any, Any]],
|
|
407
|
+
parse: RowParser,
|
|
408
|
+
) -> Dict[str, Optional[Dict[str, Any]]]:
|
|
409
|
+
"""Records for ``rows`` of ``(id, time_updated, hint)``, parsed at most
|
|
410
|
+
once per ``(store, table, id, time_updated)``.
|
|
411
|
+
|
|
412
|
+
``hint`` is handed to ``parse`` alongside the body (v2 keeps the row
|
|
413
|
+
type outside the envelope). Unparseable rows map to None.
|
|
414
|
+
"""
|
|
415
|
+
db = _database_key(conn)
|
|
416
|
+
records: Dict[str, Optional[Dict[str, Any]]] = {}
|
|
417
|
+
missing: List[Tuple[str, Any, Any]] = []
|
|
418
|
+
for msg_id, version, hint in rows:
|
|
419
|
+
entry = _row_cache.get((db, table, msg_id))
|
|
420
|
+
if entry is not None and entry[0] == version:
|
|
421
|
+
records[msg_id] = entry[1]
|
|
422
|
+
else:
|
|
423
|
+
missing.append((msg_id, version, hint))
|
|
424
|
+
if not missing:
|
|
425
|
+
return records
|
|
426
|
+
|
|
427
|
+
bodies = _fetch_data(conn, table, [msg_id for msg_id, _, _ in missing])
|
|
428
|
+
fresh: Dict[Tuple[str, str, str], Tuple[Any, Any]] = {}
|
|
429
|
+
for msg_id, version, hint in missing:
|
|
430
|
+
record, cacheable = parse(bodies.get(msg_id), hint)
|
|
431
|
+
records[msg_id] = record
|
|
432
|
+
if cacheable:
|
|
433
|
+
fresh[(db, table, msg_id)] = (version, record)
|
|
434
|
+
if fresh:
|
|
435
|
+
with _row_cache_lock:
|
|
436
|
+
if len(_row_cache) + len(fresh) > _ROW_CACHE_MAX:
|
|
437
|
+
# Bound growth: drop the oldest half (insertion order).
|
|
438
|
+
for stale in list(_row_cache)[: _ROW_CACHE_MAX // 2]:
|
|
439
|
+
del _row_cache[stale]
|
|
440
|
+
_row_cache.update(fresh)
|
|
441
|
+
return records
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _optional_ms(value: Any) -> Optional[float]:
|
|
445
|
+
return value if isinstance(value, (int, float)) else None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _parse_message_record(
|
|
449
|
+
data: Optional[str], _hint: Any
|
|
450
|
+
) -> Tuple[Optional[Dict[str, Any]], bool]:
|
|
451
|
+
"""Extract what the scan needs from a v1 ``message.data`` envelope."""
|
|
452
|
+
try:
|
|
453
|
+
envelope = json.loads(data) if data is not None else None
|
|
454
|
+
except (ValueError, TypeError):
|
|
455
|
+
return None, False
|
|
456
|
+
if not isinstance(envelope, dict):
|
|
457
|
+
return None, False
|
|
458
|
+
tokens = envelope.get("tokens")
|
|
459
|
+
tokens = tokens if isinstance(tokens, dict) else {}
|
|
460
|
+
cache = tokens.get("cache")
|
|
461
|
+
cache = cache if isinstance(cache, dict) else {}
|
|
462
|
+
times = envelope.get("time")
|
|
463
|
+
times = times if isinstance(times, dict) else {}
|
|
464
|
+
role = envelope.get("role")
|
|
465
|
+
record = {
|
|
466
|
+
"role": role,
|
|
467
|
+
"total": _as_int(tokens.get("total")),
|
|
468
|
+
"input": _as_int(tokens.get("input")),
|
|
469
|
+
"output": _as_int(tokens.get("output")),
|
|
470
|
+
"reasoning": _as_int(tokens.get("reasoning")),
|
|
471
|
+
"cache_read": _as_int(cache.get("read")),
|
|
472
|
+
"cache_write": _as_int(cache.get("write")),
|
|
473
|
+
"created": _optional_ms(times.get("created")),
|
|
474
|
+
"completed": _optional_ms(times.get("completed")),
|
|
475
|
+
}
|
|
476
|
+
in_flight = role == "assistant" and record["completed"] is None
|
|
477
|
+
return record, not in_flight
|
|
478
|
+
|
|
479
|
+
|
|
326
480
|
def _scan_messages(
|
|
327
481
|
conn: sqlite3.Connection,
|
|
328
482
|
session_ids: Sequence[str],
|
|
@@ -345,64 +499,53 @@ def _scan_messages(
|
|
|
345
499
|
if not ids:
|
|
346
500
|
return out
|
|
347
501
|
|
|
348
|
-
sql = (
|
|
349
|
-
"SELECT id, session_id, time_created, data FROM message "
|
|
350
|
-
f"WHERE session_id IN ({_placeholders(len(ids))}) "
|
|
351
|
-
"ORDER BY time_created DESC LIMIT ?"
|
|
352
|
-
)
|
|
502
|
+
sql, params = _scan_sql(ids)
|
|
353
503
|
try:
|
|
354
|
-
rows = conn.execute(sql,
|
|
504
|
+
rows = conn.execute(sql, params).fetchall()
|
|
505
|
+
rows.sort(key=lambda row: row[2], reverse=True) # time_created DESC
|
|
506
|
+
records = _cached_records(
|
|
507
|
+
conn, "message", [(row[0], row[3], None) for row in rows],
|
|
508
|
+
_parse_message_record,
|
|
509
|
+
)
|
|
355
510
|
except sqlite3.Error:
|
|
356
511
|
return out
|
|
357
512
|
|
|
358
513
|
seen_context = False
|
|
359
|
-
for
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
except (ValueError, TypeError):
|
|
514
|
+
for msg_id, session_id, time_created, _updated in rows:
|
|
515
|
+
record = records.get(msg_id)
|
|
516
|
+
if record is None:
|
|
363
517
|
continue
|
|
364
|
-
|
|
365
|
-
continue
|
|
366
|
-
role = envelope.get("role")
|
|
518
|
+
role = record["role"]
|
|
367
519
|
if role == "user":
|
|
368
520
|
out["interaction_count"] += 1
|
|
369
521
|
continue
|
|
370
522
|
if role != "assistant":
|
|
371
523
|
continue
|
|
372
524
|
|
|
373
|
-
tokens = envelope.get("tokens")
|
|
374
|
-
tokens = tokens if isinstance(tokens, dict) else {}
|
|
375
|
-
cache = tokens.get("cache")
|
|
376
|
-
cache = cache if isinstance(cache, dict) else {}
|
|
377
|
-
|
|
378
525
|
# Rows arrive newest-first, so the first assistant turn carrying a
|
|
379
526
|
# total is the live context size. Only the active conversation's
|
|
380
527
|
# context is meaningful — an older /new session's is stale.
|
|
381
528
|
if (
|
|
382
529
|
not seen_context
|
|
383
|
-
and
|
|
530
|
+
and record["total"]
|
|
384
531
|
and (active_id is None or session_id == active_id)
|
|
385
532
|
):
|
|
386
|
-
out["current_context_tokens"] =
|
|
533
|
+
out["current_context_tokens"] = record["total"]
|
|
387
534
|
seen_context = True
|
|
388
535
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
created
|
|
392
|
-
completed = times.get("completed")
|
|
393
|
-
if isinstance(created, (int, float)) and isinstance(completed, (int, float)):
|
|
536
|
+
created = record["created"]
|
|
537
|
+
completed = record["completed"]
|
|
538
|
+
if created is not None and completed is not None:
|
|
394
539
|
elapsed = (completed - created) / 1000.0
|
|
395
540
|
if elapsed > 0:
|
|
396
541
|
out["work_times"].append(elapsed)
|
|
397
542
|
|
|
398
543
|
if since_ms is not None and _as_int(time_created) >= since_ms:
|
|
399
544
|
window = out["window"]
|
|
400
|
-
window["input_tokens"] +=
|
|
401
|
-
window["output_tokens"] +=
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
window["cache_creation_tokens"] += _as_int(cache.get("write"))
|
|
405
|
-
window["cache_read_tokens"] += _as_int(cache.get("read"))
|
|
545
|
+
window["input_tokens"] += record["input"]
|
|
546
|
+
window["output_tokens"] += record["output"] + record["reasoning"]
|
|
547
|
+
window["cache_creation_tokens"] += record["cache_write"]
|
|
548
|
+
window["cache_read_tokens"] += record["cache_read"]
|
|
406
549
|
|
|
407
550
|
out["work_times"].reverse()
|
|
408
551
|
return out
|
|
@@ -697,6 +840,7 @@ def _launch_ms(session: Any) -> Optional[int]:
|
|
|
697
840
|
|
|
698
841
|
__all__ = [
|
|
699
842
|
"EXPECTED_MESSAGE_COLUMNS",
|
|
843
|
+
"clear_row_cache",
|
|
700
844
|
"EXPECTED_SESSION_COLUMNS",
|
|
701
845
|
"OpencodeStatsReader",
|
|
702
846
|
"connect",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Console-script entry point for the ``overcode`` command.
|
|
2
|
+
|
|
3
|
+
The typer CLI in :mod:`overcode.cli` imports typer, rich, libtmux and the
|
|
4
|
+
launcher before it can dispatch anything — roughly 70 ms of CPU per
|
|
5
|
+
process. That is fine for a human typing commands, but the same binary is
|
|
6
|
+
what every agent's hooks run (``overcode hook-handler``) on *every*
|
|
7
|
+
PreToolUse / PostToolUse / Stop / ... event. Across a fleet of busy agents
|
|
8
|
+
that adds up to whole cores burned on Python imports, competing with the
|
|
9
|
+
tmux server and the terminal for CPU — visible as typing lag.
|
|
10
|
+
|
|
11
|
+
So the hook path is dispatched here, before the CLI package is imported.
|
|
12
|
+
Everything else falls through to the full typer app unchanged.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# Subcommands that must not pay for the typer CLI import. Only ``hook-handler``
|
|
18
|
+
# today; each entry maps to a zero-argument callable resolved lazily.
|
|
19
|
+
_FAST_PATHS = {
|
|
20
|
+
"hook-handler": ("overcode.hook_handler", "handle_hook_event"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
"""Dispatch ``overcode <cmd>``; fast-path hook events, else run the CLI."""
|
|
26
|
+
argv = sys.argv[1:]
|
|
27
|
+
if len(argv) == 1 and argv[0] in _FAST_PATHS:
|
|
28
|
+
module_name, func_name = _FAST_PATHS[argv[0]]
|
|
29
|
+
import importlib
|
|
30
|
+
|
|
31
|
+
getattr(importlib.import_module(module_name), func_name)()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
from .cli import main as cli_main
|
|
35
|
+
|
|
36
|
+
cli_main()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -15,7 +15,7 @@ import os
|
|
|
15
15
|
import time
|
|
16
16
|
import uuid
|
|
17
17
|
from pathlib import Path
|
|
18
|
-
from typing import List, Optional
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
19
|
|
|
20
20
|
import re
|
|
21
21
|
|
|
@@ -45,6 +45,26 @@ from .exceptions import TmuxNotFoundError, AgentCliNotFoundError, InvalidSession
|
|
|
45
45
|
SESSION_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def window_lookup(windows: List[Dict[str, Any]]) -> Tuple[set, set]:
|
|
49
|
+
"""Build (names, index-strings) sets from a ``list_windows()`` result.
|
|
50
|
+
|
|
51
|
+
Lets callers answer "does window X exist?" for many X from one tmux
|
|
52
|
+
round-trip, with the same semantics as ``TmuxManager.window_exists``:
|
|
53
|
+
exact name match, falling back to a legacy digit-string index match.
|
|
54
|
+
"""
|
|
55
|
+
names = {w.get("name", "") for w in windows}
|
|
56
|
+
indices = {str(w.get("index")) for w in windows if w.get("index") is not None}
|
|
57
|
+
return names, indices
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def window_in_lookup(window: str, lookup: Tuple[set, set]) -> bool:
|
|
61
|
+
"""Existence check against ``window_lookup`` output (see there)."""
|
|
62
|
+
names, indices = lookup
|
|
63
|
+
if window in names:
|
|
64
|
+
return True
|
|
65
|
+
return window.isdigit() and window in indices
|
|
66
|
+
|
|
67
|
+
|
|
48
68
|
def validate_session_name(name: str) -> None:
|
|
49
69
|
"""Validate session name format.
|
|
50
70
|
|
|
@@ -904,22 +924,29 @@ class AgentLauncher:
|
|
|
904
924
|
# Filter to only sessions belonging to this tmux session
|
|
905
925
|
my_sessions = [s for s in all_sessions if s.tmux_session == self.tmux.session_name]
|
|
906
926
|
|
|
927
|
+
# One tmux round-trip for the whole call: the window list serves both
|
|
928
|
+
# the legacy-index migration and the existence checks below. The old
|
|
929
|
+
# per-session window_exists() cost two tmux commands per agent, and
|
|
930
|
+
# the TUI calls this every 10s.
|
|
931
|
+
tmux_windows = self._list_tmux_windows_cheap()
|
|
932
|
+
|
|
907
933
|
# Migrate legacy digit-string tmux_window values to actual window names.
|
|
908
934
|
# Pre-name-based sessions stored window index (e.g. 4 → "4") but the new
|
|
909
935
|
# code expects the window name (e.g. "overcode2"). Resolve via tmux.
|
|
910
|
-
self._migrate_legacy_window_ids(my_sessions)
|
|
936
|
+
self._migrate_legacy_window_ids(my_sessions, tmux_windows)
|
|
911
937
|
|
|
912
938
|
# Detect terminated sessions (tmux window gone but session still tracked)
|
|
913
939
|
if detect_terminated:
|
|
914
940
|
from .follow_mode import _check_hook_stop, _check_report
|
|
915
941
|
from .status_constants import STATUS_WAITING_OVERSIGHT
|
|
916
942
|
|
|
943
|
+
existing = window_lookup(tmux_windows)
|
|
917
944
|
newly_terminated = []
|
|
918
945
|
newly_done: list[str] = [] # session ids of children that just flipped to done (#432)
|
|
919
946
|
for session in my_sessions:
|
|
920
947
|
# Only check non-terminated sessions
|
|
921
948
|
if session.status not in ("terminated", "done"):
|
|
922
|
-
if not
|
|
949
|
+
if not window_in_lookup(session.tmux_window, existing):
|
|
923
950
|
# Child agents with Stop hook: check for report first
|
|
924
951
|
if (session.parent_session_id is not None
|
|
925
952
|
and _check_hook_stop(self.tmux.session_name, session.name)):
|
|
@@ -1162,14 +1189,32 @@ class AgentLauncher:
|
|
|
1162
1189
|
|
|
1163
1190
|
return success
|
|
1164
1191
|
|
|
1165
|
-
def
|
|
1192
|
+
def _list_tmux_windows_cheap(self) -> List[Dict[str, Any]]:
|
|
1193
|
+
"""Window list without per-window pane commands (one tmux command).
|
|
1194
|
+
|
|
1195
|
+
Tolerates tmux managers that don't accept ``include_command``
|
|
1196
|
+
(test doubles, older subclasses).
|
|
1197
|
+
"""
|
|
1198
|
+
try:
|
|
1199
|
+
return self.tmux.list_windows(include_command=False)
|
|
1200
|
+
except TypeError:
|
|
1201
|
+
return self.tmux.list_windows()
|
|
1202
|
+
|
|
1203
|
+
def _migrate_legacy_window_ids(
|
|
1204
|
+
self, sessions: List[Session], windows: Optional[List[Dict[str, Any]]] = None
|
|
1205
|
+
) -> None:
|
|
1166
1206
|
"""Migrate legacy digit-string tmux_window values to actual window names.
|
|
1167
1207
|
|
|
1168
1208
|
Legacy sessions stored the window index (int) which got converted to a
|
|
1169
1209
|
digit string like "4". We resolve these to the actual window name by
|
|
1170
1210
|
looking up the tmux window list by index.
|
|
1211
|
+
|
|
1212
|
+
Args:
|
|
1213
|
+
windows: pre-fetched ``list_windows()`` result to avoid a second
|
|
1214
|
+
tmux round-trip; fetched here when omitted.
|
|
1171
1215
|
"""
|
|
1172
|
-
windows
|
|
1216
|
+
if windows is None:
|
|
1217
|
+
windows = self._list_tmux_windows_cheap()
|
|
1173
1218
|
if not windows:
|
|
1174
1219
|
return
|
|
1175
1220
|
index_to_name = {str(w['index']): w['name'] for w in windows}
|