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.
Files changed (168) hide show
  1. {overcode-0.5.3/src/overcode.egg-info → overcode-0.5.5}/PKG-INFO +2 -2
  2. {overcode-0.5.3 → overcode-0.5.5}/README.md +1 -1
  3. {overcode-0.5.3 → overcode-0.5.5}/pyproject.toml +2 -2
  4. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode.py +4 -1
  5. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_stats.py +89 -43
  6. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode_stats.py +176 -32
  7. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/_shared.py +18 -0
  8. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/agent.py +90 -10
  9. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/budget.py +6 -6
  10. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/focal.py +2 -2
  11. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/jobs.py +2 -2
  12. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/monitoring.py +6 -6
  13. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/split.py +63 -30
  14. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/tags.py +4 -4
  15. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/config.py +65 -0
  16. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/data_export.py +5 -0
  17. overcode-0.5.5/src/overcode/entrypoint.py +40 -0
  18. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/exceptions.py +9 -0
  19. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/follow_mode.py +63 -16
  20. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/history_reader.py +274 -112
  21. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hook_handler.py +100 -10
  22. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hook_status_detector.py +74 -7
  23. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/implementations.py +37 -1
  24. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/launcher.py +304 -32
  25. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/mocks.py +45 -2
  26. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon.py +506 -107
  27. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon_core.py +16 -0
  28. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/monitor_daemon_state.py +92 -15
  29. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +62 -15
  30. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry.js +161 -21
  31. overcode-0.5.5/src/overcode/pane_capture_gate.py +140 -0
  32. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/presence_logger.py +85 -14
  33. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/protocols.py +26 -0
  34. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/session_manager.py +767 -137
  35. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/settings.py +58 -0
  36. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sister_controller.py +0 -8
  37. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/standing_instructions.py +0 -21
  38. overcode-0.5.5/src/overcode/stat_gate.py +123 -0
  39. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/stats_reader.py +44 -14
  40. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_detector.py +14 -1
  41. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_detector_factory.py +14 -0
  42. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_history.py +316 -48
  43. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summarizer_component.py +23 -4
  44. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_daemon.py +4 -2
  45. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tmux_manager.py +28 -9
  46. overcode-0.5.5/src/overcode/tmux_utils.py +573 -0
  47. overcode-0.5.5/src/overcode/transcript_index.py +557 -0
  48. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui.py +740 -198
  49. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui.tcss +16 -0
  50. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/session.py +32 -69
  51. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_logic.py +213 -21
  52. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/__init__.py +2 -0
  53. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/daemon_status_bar.py +23 -6
  54. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/help_overlay.py +4 -3
  55. overcode-0.5.5/src/overcode/tui_widgets/rename_agent_modal.py +188 -0
  56. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/status_timeline.py +7 -35
  57. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_api.py +19 -7
  58. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_control_api.py +3 -31
  59. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_server.py +8 -1
  60. overcode-0.5.5/src/overcode/worker_guard.py +163 -0
  61. {overcode-0.5.3 → overcode-0.5.5/src/overcode.egg-info}/PKG-INFO +2 -2
  62. {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/SOURCES.txt +6 -0
  63. {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/entry_points.txt +1 -1
  64. overcode-0.5.3/src/overcode/tmux_utils.py +0 -285
  65. {overcode-0.5.3 → overcode-0.5.5}/LICENSE +0 -0
  66. {overcode-0.5.3 → overcode-0.5.5}/MANIFEST.in +0 -0
  67. {overcode-0.5.3 → overcode-0.5.5}/setup.cfg +0 -0
  68. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/__init__.py +0 -0
  69. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/agent_scanner.py +0 -0
  70. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/__init__.py +0 -0
  71. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/base.py +0 -0
  72. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/claude_code.py +0 -0
  73. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/codex.py +0 -0
  74. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/codex_stats.py +0 -0
  75. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/grok.py +0 -0
  76. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/grok_stats.py +0 -0
  77. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/hermes.py +0 -0
  78. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/hermes_stats.py +0 -0
  79. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2.py +0 -0
  80. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_patterns.py +0 -0
  81. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/backends/opencode2_plugin_install.py +0 -0
  82. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/bundled_skills.py +0 -0
  83. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/claude_config.py +0 -0
  84. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/claude_pid.py +0 -0
  85. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/__init__.py +0 -0
  86. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/__main__.py +0 -0
  87. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/config.py +0 -0
  88. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/daemon.py +0 -0
  89. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/doctor.py +0 -0
  90. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/hooks.py +0 -0
  91. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/models.py +0 -0
  92. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/parallelism.py +0 -0
  93. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/perms.py +0 -0
  94. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/sister.py +0 -0
  95. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/skills.py +0 -0
  96. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/cli/wrappers.py +0 -0
  97. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_claude_skill.md +0 -0
  98. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_logging.py +0 -0
  99. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/daemon_utils.py +0 -0
  100. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/data/model_metadata.json +0 -0
  101. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/dependency_check.py +0 -0
  102. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/doctor.py +0 -0
  103. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/duration.py +0 -0
  104. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hermes_plugin/__init__.py +0 -0
  105. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/hermes_plugin/plugin.yaml +0 -0
  106. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/interfaces.py +0 -0
  107. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/job_launcher.py +0 -0
  108. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/job_manager.py +0 -0
  109. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/logging_config.py +0 -0
  110. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/model_metadata.py +0 -0
  111. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/notifier.py +0 -0
  112. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/index.js +0 -0
  113. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/opencode_plugin/overcode-telemetry-v2/tui.js +0 -0
  114. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/pid_utils.py +0 -0
  115. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/pricing.py +0 -0
  116. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/process_resources.py +0 -0
  117. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sandbox_detect.py +0 -0
  118. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/sister_poller.py +0 -0
  119. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/ssh_provisioner.py +0 -0
  120. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_constants.py +0 -0
  121. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/status_patterns.py +0 -0
  122. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summarizer_client.py +0 -0
  123. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summary_columns.py +0 -0
  124. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/summary_groups.py +0 -0
  125. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_daemon_core.py +0 -0
  126. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/supervisor_layout.sh +0 -0
  127. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/__init__.py +0 -0
  128. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/renderer.py +0 -0
  129. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tmux_driver.py +0 -0
  130. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tui_eye.py +0 -0
  131. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/testing/tui_eye_skill.md +0 -0
  132. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/time_context.py +0 -0
  133. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/__init__.py +0 -0
  134. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/daemon.py +0 -0
  135. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/input.py +0 -0
  136. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/navigation.py +0 -0
  137. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_actions/view.py +0 -0
  138. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_helpers.py +0 -0
  139. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_render.py +0 -0
  140. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  141. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/command_bar.py +0 -0
  142. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  143. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  144. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  145. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/job_summary.py +0 -0
  146. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/jump_modal.py +0 -0
  147. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/modal_base.py +0 -0
  148. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  149. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/new_agent_modal.py +0 -0
  150. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/passthru_config_modal.py +0 -0
  151. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/preview_pane.py +0 -0
  152. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/session_summary.py +0 -0
  153. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  154. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  155. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/tmux_config_modal.py +0 -0
  156. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
  157. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/usage_monitor.py +0 -0
  158. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/__init__.py +0 -0
  159. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/templates/analytics.html +0 -0
  160. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web/templates/dashboard.html +0 -0
  161. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_chartjs.py +0 -0
  162. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_server_runner.py +0 -0
  163. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/web_templates.py +0 -0
  164. {overcode-0.5.3 → overcode-0.5.5}/src/overcode/wrapper.py +0 -0
  165. {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/dependency_links.txt +0 -0
  166. {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/requires.txt +0 -0
  167. {overcode-0.5.3 → overcode-0.5.5}/src/overcode.egg-info/top_level.txt +0 -0
  168. {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
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, handover, remote monitoring
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, handover, remote monitoring
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.3"
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.cli:main"
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, (*ids, _MESSAGE_SCAN_LIMIT * max(1, len(ids)))).fetchall()
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 _msg_id, session_id, mtype, time_created, data in rows:
262
- try:
263
- envelope = json.loads(data)
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
- parsed_model = _parse_model(envelope.get("model"))
301
- if parsed_model:
302
- out["model"] = parsed_model
303
- if out["agent"] is None:
304
- agent = envelope.get("agent")
305
- if isinstance(agent, str) and agent:
306
- out["agent"] = agent
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"] += _as_int(tokens.get("input"))
320
- window["output_tokens"] += _as_int(tokens.get("output")) + _as_int(
321
- tokens.get("reasoning")
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, (*ids, _MESSAGE_SCAN_LIMIT * max(1, len(ids)))).fetchall()
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 _msg_id, session_id, time_created, data in rows:
360
- try:
361
- envelope = json.loads(data)
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
- if not isinstance(envelope, dict):
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 tokens.get("total")
530
+ and record["total"]
384
531
  and (active_id is None or session_id == active_id)
385
532
  ):
386
- out["current_context_tokens"] = _as_int(tokens.get("total"))
533
+ out["current_context_tokens"] = record["total"]
387
534
  seen_context = True
388
535
 
389
- times = envelope.get("time")
390
- times = times if isinstance(times, dict) else {}
391
- created = times.get("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"] += _as_int(tokens.get("input"))
401
- window["output_tokens"] += _as_int(tokens.get("output")) + _as_int(
402
- tokens.get("reasoning")
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[