overcode 0.5.3__tar.gz → 0.5.5__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.5}/PKG-INFO +2 -2
- {overcode-0.5.3 → overcode-0.5.5}/README.md +1 -1
- {overcode-0.5.3 → overcode-0.5.5}/pyproject.toml +2 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode.py +4 -1
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_stats.py +89 -43
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode_stats.py +176 -32
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/_shared.py +18 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/agent.py +90 -10
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/budget.py +6 -6
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/focal.py +2 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/jobs.py +2 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/monitoring.py +6 -6
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/split.py +63 -30
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/tags.py +4 -4
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/config.py +65 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/data_export.py +5 -0
- overcode-0.5.5/src/overcode/entrypoint.py +40 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/exceptions.py +9 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/follow_mode.py +63 -16
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/history_reader.py +274 -112
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hook_handler.py +100 -10
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hook_status_detector.py +74 -7
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/implementations.py +37 -1
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/launcher.py +304 -32
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/mocks.py +45 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon.py +506 -107
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon_core.py +16 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon_state.py +92 -15
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +62 -15
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry.js +161 -21
- overcode-0.5.5/src/overcode/pane_capture_gate.py +140 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/presence_logger.py +85 -14
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/protocols.py +26 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/session_manager.py +767 -137
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/settings.py +58 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sister_controller.py +0 -8
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/standing_instructions.py +0 -21
- overcode-0.5.5/src/overcode/stat_gate.py +123 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/stats_reader.py +44 -14
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_detector.py +14 -1
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_detector_factory.py +14 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_history.py +316 -48
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summarizer_component.py +23 -4
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_daemon.py +4 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tmux_manager.py +28 -9
- overcode-0.5.5/src/overcode/tmux_utils.py +573 -0
- overcode-0.5.5/src/overcode/transcript_index.py +557 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui.py +740 -198
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui.tcss +16 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/session.py +32 -69
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_logic.py +213 -21
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/__init__.py +2 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/daemon_status_bar.py +23 -6
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/help_overlay.py +4 -3
- overcode-0.5.5/src/overcode/tui_widgets/rename_agent_modal.py +188 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/status_timeline.py +7 -35
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_api.py +19 -7
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_control_api.py +3 -31
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_server.py +8 -1
- overcode-0.5.5/src/overcode/worker_guard.py +163 -0
- {overcode-0.5.3 → overcode-0.5.5/src/overcode.egg-info}/PKG-INFO +2 -2
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/SOURCES.txt +6 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/entry_points.txt +1 -1
- overcode-0.5.3/src/overcode/tmux_utils.py +0 -285
- {overcode-0.5.3 → overcode-0.5.5}/LICENSE +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/MANIFEST.in +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/setup.cfg +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/base.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/claude_code.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/codex.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/codex_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/grok.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/grok_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/hermes.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/hermes_stats.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_patterns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_plugin_install.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/claude_config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/claude_pid.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/doctor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/models.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/parallelism.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/perms.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/sister.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/skills.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/wrappers.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/data/model_metadata.json +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/dependency_check.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/doctor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/duration.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hermes_plugin/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hermes_plugin/plugin.yaml +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/interfaces.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/job_launcher.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/job_manager.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/logging_config.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/model_metadata.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/notifier.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/index.js +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/tui.js +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/pid_utils.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/pricing.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/process_resources.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sandbox_detect.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sister_poller.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/ssh_provisioner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_constants.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_patterns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summary_columns.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summary_groups.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/time_context.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/input.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/view.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_render.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/job_summary.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/jump_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/modal_base.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/new_agent_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/passthru_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/session_summary.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/tmux_config_modal.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/__init__.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_templates.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode/wrapper.py +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.5.3 → overcode-0.5.5}/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.5.
|
|
3
|
+
Version: 0.5.5
|
|
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
|
|
@@ -176,7 +176,7 @@ See the [TUI Guide](docs/tui-guide.md) for all keyboard shortcuts.
|
|
|
176
176
|
- [TUI Guide](docs/tui-guide.md) - Keyboard shortcuts and display modes
|
|
177
177
|
- [Configuration](docs/configuration.md) - Config file and environment variables
|
|
178
178
|
- [Wrappers](docs/wrappers.md) - Run agents in containers and custom environments
|
|
179
|
-
- [Advanced Features](docs/advanced-features.md) - Sleep mode,
|
|
179
|
+
- [Advanced Features](docs/advanced-features.md) - Sleep mode, remote monitoring
|
|
180
180
|
|
|
181
181
|
## Testing
|
|
182
182
|
|
|
@@ -134,7 +134,7 @@ See the [TUI Guide](docs/tui-guide.md) for all keyboard shortcuts.
|
|
|
134
134
|
- [TUI Guide](docs/tui-guide.md) - Keyboard shortcuts and display modes
|
|
135
135
|
- [Configuration](docs/configuration.md) - Config file and environment variables
|
|
136
136
|
- [Wrappers](docs/wrappers.md) - Run agents in containers and custom environments
|
|
137
|
-
- [Advanced Features](docs/advanced-features.md) - Sleep mode,
|
|
137
|
+
- [Advanced Features](docs/advanced-features.md) - Sleep mode, remote monitoring
|
|
138
138
|
|
|
139
139
|
## Testing
|
|
140
140
|
|
|
@@ -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.5"
|
|
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]
|
|
@@ -14,7 +14,10 @@ remains the fallback for a launch where the plugin could not be installed.
|
|
|
14
14
|
Everything below the flag table was captured from a real opencode
|
|
15
15
|
v1.18.19 session; the pane corpus lives in
|
|
16
16
|
``tests/fixtures_opencode_panes/`` and is replayed by
|
|
17
|
-
``tests/unit/test_status_detector_opencode.py``.
|
|
17
|
+
``tests/unit/test_status_detector_opencode.py``. Re-verified unchanged
|
|
18
|
+
against v1.18.29 (Sep 2026, #474): a second pane corpus under
|
|
19
|
+
``tests/fixtures_opencode_panes/v1.18.29/`` and the plugin's event-stream
|
|
20
|
+
corpus in ``tests/fixtures_opencode_events/``.
|
|
18
21
|
"""
|
|
19
22
|
|
|
20
23
|
import json
|
|
@@ -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",
|
|
@@ -109,6 +109,24 @@ app.add_typer(models_app, name="models")
|
|
|
109
109
|
|
|
110
110
|
# Console for rich output
|
|
111
111
|
console = Console()
|
|
112
|
+
# Notices that must not mix into output a script may parse
|
|
113
|
+
err_console = Console(stderr=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def find_agent(manager, name: str):
|
|
117
|
+
"""Look up the agent ``name`` addresses, following renames (#478).
|
|
118
|
+
|
|
119
|
+
``manager`` is a SessionManager. When ``name`` is an agent's old name,
|
|
120
|
+
the agent is returned and a one-line notice goes to stderr, so a person
|
|
121
|
+
or script still using the old name learns the new one.
|
|
122
|
+
"""
|
|
123
|
+
from ..session_manager import rename_notice
|
|
124
|
+
|
|
125
|
+
session = manager.resolve_session_name(name)
|
|
126
|
+
notice = rename_notice(name, session)
|
|
127
|
+
if notice:
|
|
128
|
+
err_console.print(f"[yellow]{notice}[/yellow]")
|
|
129
|
+
return session
|
|
112
130
|
|
|
113
131
|
# Global session option (hidden advanced usage)
|
|
114
132
|
SessionOption = Annotated[
|