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.
Files changed (162) hide show
  1. {overcode-0.5.3/src/overcode.egg-info → overcode-0.5.4}/PKG-INFO +1 -1
  2. {overcode-0.5.3 → overcode-0.5.4}/pyproject.toml +2 -2
  3. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_stats.py +89 -43
  4. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode_stats.py +176 -32
  5. overcode-0.5.4/src/overcode/entrypoint.py +40 -0
  6. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/launcher.py +50 -5
  7. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/session_manager.py +71 -1
  8. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tmux_manager.py +9 -2
  9. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui.py +102 -17
  10. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_logic.py +68 -0
  11. {overcode-0.5.3 → overcode-0.5.4/src/overcode.egg-info}/PKG-INFO +1 -1
  12. {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/SOURCES.txt +1 -0
  13. {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/entry_points.txt +1 -1
  14. {overcode-0.5.3 → overcode-0.5.4}/LICENSE +0 -0
  15. {overcode-0.5.3 → overcode-0.5.4}/MANIFEST.in +0 -0
  16. {overcode-0.5.3 → overcode-0.5.4}/README.md +0 -0
  17. {overcode-0.5.3 → overcode-0.5.4}/setup.cfg +0 -0
  18. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/__init__.py +0 -0
  19. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/agent_scanner.py +0 -0
  20. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/__init__.py +0 -0
  21. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/base.py +0 -0
  22. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/claude_code.py +0 -0
  23. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/codex.py +0 -0
  24. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/codex_stats.py +0 -0
  25. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/grok.py +0 -0
  26. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/grok_stats.py +0 -0
  27. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/hermes.py +0 -0
  28. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/hermes_stats.py +0 -0
  29. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode.py +0 -0
  30. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2.py +0 -0
  31. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_patterns.py +0 -0
  32. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/backends/opencode2_plugin_install.py +0 -0
  33. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/bundled_skills.py +0 -0
  34. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/claude_config.py +0 -0
  35. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/claude_pid.py +0 -0
  36. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/__init__.py +0 -0
  37. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/__main__.py +0 -0
  38. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/_shared.py +0 -0
  39. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/agent.py +0 -0
  40. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/budget.py +0 -0
  41. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/config.py +0 -0
  42. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/daemon.py +0 -0
  43. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/doctor.py +0 -0
  44. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/focal.py +0 -0
  45. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/hooks.py +0 -0
  46. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/jobs.py +0 -0
  47. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/models.py +0 -0
  48. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/monitoring.py +0 -0
  49. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/parallelism.py +0 -0
  50. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/perms.py +0 -0
  51. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/sister.py +0 -0
  52. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/skills.py +0 -0
  53. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/split.py +0 -0
  54. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/tags.py +0 -0
  55. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/cli/wrappers.py +0 -0
  56. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/config.py +0 -0
  57. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_claude_skill.md +0 -0
  58. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_logging.py +0 -0
  59. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/daemon_utils.py +0 -0
  60. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/data/model_metadata.json +0 -0
  61. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/data_export.py +0 -0
  62. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/dependency_check.py +0 -0
  63. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/doctor.py +0 -0
  64. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/duration.py +0 -0
  65. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/exceptions.py +0 -0
  66. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/follow_mode.py +0 -0
  67. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hermes_plugin/__init__.py +0 -0
  68. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hermes_plugin/plugin.yaml +0 -0
  69. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/history_reader.py +0 -0
  70. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hook_handler.py +0 -0
  71. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/hook_status_detector.py +0 -0
  72. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/implementations.py +0 -0
  73. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/interfaces.py +0 -0
  74. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/job_launcher.py +0 -0
  75. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/job_manager.py +0 -0
  76. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/logging_config.py +0 -0
  77. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/mocks.py +0 -0
  78. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/model_metadata.py +0 -0
  79. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon.py +0 -0
  80. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon_core.py +0 -0
  81. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/monitor_daemon_state.py +0 -0
  82. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/notifier.py +0 -0
  83. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/index.js +0 -0
  84. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +0 -0
  85. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry-v2/tui.js +0 -0
  86. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/opencode_plugin/overcode-telemetry.js +0 -0
  87. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/pid_utils.py +0 -0
  88. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/presence_logger.py +0 -0
  89. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/pricing.py +0 -0
  90. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/process_resources.py +0 -0
  91. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/protocols.py +0 -0
  92. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sandbox_detect.py +0 -0
  93. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/settings.py +0 -0
  94. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sister_controller.py +0 -0
  95. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/sister_poller.py +0 -0
  96. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/ssh_provisioner.py +0 -0
  97. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/standing_instructions.py +0 -0
  98. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/stats_reader.py +0 -0
  99. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_constants.py +0 -0
  100. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_detector.py +0 -0
  101. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_detector_factory.py +0 -0
  102. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_history.py +0 -0
  103. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/status_patterns.py +0 -0
  104. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summarizer_client.py +0 -0
  105. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summarizer_component.py +0 -0
  106. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summary_columns.py +0 -0
  107. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/summary_groups.py +0 -0
  108. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_daemon.py +0 -0
  109. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_daemon_core.py +0 -0
  110. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/supervisor_layout.sh +0 -0
  111. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/__init__.py +0 -0
  112. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/renderer.py +0 -0
  113. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tmux_driver.py +0 -0
  114. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tui_eye.py +0 -0
  115. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/testing/tui_eye_skill.md +0 -0
  116. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/time_context.py +0 -0
  117. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tmux_utils.py +0 -0
  118. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui.tcss +0 -0
  119. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/__init__.py +0 -0
  120. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/daemon.py +0 -0
  121. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/input.py +0 -0
  122. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/navigation.py +0 -0
  123. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/session.py +0 -0
  124. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_actions/view.py +0 -0
  125. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_helpers.py +0 -0
  126. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_render.py +0 -0
  127. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/__init__.py +0 -0
  128. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  129. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/command_bar.py +0 -0
  130. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  131. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
  132. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  133. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/help_overlay.py +0 -0
  134. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  135. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/job_summary.py +0 -0
  136. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/jump_modal.py +0 -0
  137. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/modal_base.py +0 -0
  138. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  139. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/new_agent_modal.py +0 -0
  140. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/passthru_config_modal.py +0 -0
  141. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/preview_pane.py +0 -0
  142. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/session_summary.py +0 -0
  143. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  144. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/status_timeline.py +0 -0
  145. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  146. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/tmux_config_modal.py +0 -0
  147. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/tui_widgets/tui_log_panel.py +0 -0
  148. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/usage_monitor.py +0 -0
  149. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/__init__.py +0 -0
  150. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/templates/analytics.html +0 -0
  151. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web/templates/dashboard.html +0 -0
  152. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_api.py +0 -0
  153. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_chartjs.py +0 -0
  154. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_control_api.py +0 -0
  155. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_server.py +0 -0
  156. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_server_runner.py +0 -0
  157. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/web_templates.py +0 -0
  158. {overcode-0.5.3 → overcode-0.5.4}/src/overcode/wrapper.py +0 -0
  159. {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/dependency_links.txt +0 -0
  160. {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/requires.txt +0 -0
  161. {overcode-0.5.3 → overcode-0.5.4}/src/overcode.egg-info/top_level.txt +0 -0
  162. {overcode-0.5.3 → overcode-0.5.4}/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.4
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.5.3"
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.cli:main"
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, (*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",
@@ -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 self.tmux.window_exists(session.tmux_window):
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 _migrate_legacy_window_ids(self, sessions: List[Session]) -> None:
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 = self.tmux.list_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}