overcode 0.5.8__py3-none-any.whl → 0.6.2__py3-none-any.whl

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 (277) hide show
  1. overcode/activity_log.py +6 -4
  2. overcode/agent_bundle.py +422 -0
  3. overcode/agent_files.py +78 -0
  4. overcode/agent_identity.py +50 -0
  5. overcode/agent_io.py +399 -0
  6. overcode/agent_worktree.py +125 -0
  7. overcode/backends/__init__.py +2 -0
  8. overcode/backends/_stats_common.py +234 -0
  9. overcode/backends/base.py +147 -65
  10. overcode/backends/claude_code.py +48 -33
  11. overcode/backends/codex.py +71 -87
  12. overcode/backends/codex_stats.py +305 -191
  13. overcode/backends/grok.py +55 -89
  14. overcode/backends/grok_stats.py +168 -70
  15. overcode/backends/hermes.py +97 -99
  16. overcode/backends/hermes_stats.py +92 -136
  17. overcode/backends/jsonl_tail.py +263 -0
  18. overcode/backends/opencode.py +90 -75
  19. overcode/backends/opencode2.py +9 -19
  20. overcode/backends/opencode2_patterns.py +16 -49
  21. overcode/backends/opencode2_stats.py +37 -214
  22. overcode/backends/opencode_stats.py +366 -267
  23. overcode/backends/shell.py +19 -32
  24. overcode/bundled_skills.py +26 -8
  25. overcode/burn.py +159 -0
  26. overcode/child_lifecycle.py +155 -0
  27. overcode/child_presets.py +270 -0
  28. overcode/claude_bg.py +998 -0
  29. overcode/claude_bg_ledger.py +425 -0
  30. overcode/claude_config.py +0 -50
  31. overcode/cli/__init__.py +6 -0
  32. overcode/cli/_shared.py +12 -3
  33. overcode/cli/agent.py +558 -159
  34. overcode/cli/budget.py +518 -1
  35. overcode/cli/config.py +87 -35
  36. overcode/cli/daemon.py +5 -3
  37. overcode/cli/doctor.py +80 -8
  38. overcode/cli/hooks.py +1 -6
  39. overcode/cli/jobs.py +9 -5
  40. overcode/cli/keys.py +1 -1
  41. overcode/cli/lifecycle.py +14 -4
  42. overcode/cli/models.py +161 -12
  43. overcode/cli/monitoring.py +186 -100
  44. overcode/cli/naming.py +432 -0
  45. overcode/cli/parallelism.py +1 -1
  46. overcode/cli/quota.py +128 -0
  47. overcode/cli/sister.py +23 -17
  48. overcode/cli/split.py +70 -26
  49. overcode/cli/tags.py +1 -1
  50. overcode/cli/trip.py +132 -0
  51. overcode/cli/wrappers.py +2 -3
  52. overcode/clock.py +234 -0
  53. overcode/codes.py +495 -0
  54. overcode/columns/__init__.py +1 -0
  55. overcode/columns/column.py +133 -0
  56. overcode/columns/context.py +379 -0
  57. overcode/columns/emoji.py +87 -0
  58. overcode/columns/hover.py +271 -0
  59. overcode/columns/layout.py +153 -0
  60. overcode/columns/registry.py +807 -0
  61. overcode/columns/render_context.py +134 -0
  62. overcode/columns/render_git.py +80 -0
  63. overcode/columns/render_identity.py +231 -0
  64. overcode/columns/render_performance.py +174 -0
  65. overcode/columns/render_supervision.py +313 -0
  66. overcode/columns/render_time.py +44 -0
  67. overcode/columns/render_usage.py +300 -0
  68. overcode/columns/render_virtual.py +325 -0
  69. overcode/command_palette.py +96 -13
  70. overcode/config.py +860 -129
  71. overcode/conversation_spend.py +619 -0
  72. overcode/conversation_warning.py +108 -0
  73. overcode/cost_check.py +163 -0
  74. overcode/daemon_cadence.py +60 -0
  75. overcode/daemon_housekeeping.py +457 -0
  76. overcode/daemon_logging.py +37 -1
  77. overcode/daemon_presence.py +182 -0
  78. overcode/daemon_process_probe.py +132 -0
  79. overcode/daemon_stats_sync.py +491 -0
  80. overcode/daemon_utils.py +0 -1
  81. overcode/data/claude_prices.json +274 -0
  82. overcode/data_export.py +0 -1
  83. overcode/dependency_check.py +2 -26
  84. overcode/docs/README.md +1 -0
  85. overcode/docs/advanced-features.md +65 -71
  86. overcode/docs/architecture.md +12 -9
  87. overcode/docs/backends.md +111 -37
  88. overcode/docs/children.md +276 -0
  89. overcode/docs/claude-session-files.md +47 -5
  90. overcode/docs/cli-reference.md +256 -49
  91. overcode/docs/configuration.md +853 -94
  92. overcode/docs/design/README.md +7 -0
  93. overcode/docs/design/agent-backends-codex-grok.md +4 -2
  94. overcode/docs/design/agent-observability-0.6.1.md +1034 -0
  95. overcode/docs/design/auto-naming.md +499 -0
  96. overcode/docs/design/background-shell-classification.md +1 -1
  97. overcode/docs/design/bakeoffs/zellij-vs-tmux.md +1 -1
  98. overcode/docs/design/billing-code-ids.md +165 -0
  99. overcode/docs/design/cost-model-0.6.1.md +499 -0
  100. overcode/docs/design/engine-0.6.md +305 -0
  101. overcode/docs/design/opencode-parity.md +239 -0
  102. overcode/docs/design/opencode-stats-reader-performance.md +64 -5
  103. overcode/docs/design/quality-review-2026-10.md +378 -0
  104. overcode/docs/getting-started.md +9 -19
  105. overcode/docs/release-notes-0.6.0.md +8 -0
  106. overcode/docs/release-notes-0.6.1.md +49 -0
  107. overcode/docs/release-notes-0.6.2.md +27 -0
  108. overcode/docs/releasing.md +54 -0
  109. overcode/docs/tui-guide.md +611 -46
  110. overcode/doctor.py +379 -12
  111. overcode/energy.py +239 -0
  112. overcode/engine_protocol.py +275 -0
  113. overcode/engine_recorders.py +209 -0
  114. overcode/engine_socket.py +524 -0
  115. overcode/engine_wake.py +264 -0
  116. overcode/entrypoint.py +26 -12
  117. overcode/episodes.py +417 -0
  118. overcode/exceptions.py +15 -126
  119. overcode/follow_mode.py +40 -52
  120. overcode/git_context.py +336 -0
  121. overcode/git_stats.py +103 -0
  122. overcode/history_reader.py +653 -213
  123. overcode/hook_dialects.py +226 -0
  124. overcode/hook_handler.py +657 -310
  125. overcode/hook_status_detector.py +828 -458
  126. overcode/idle_stages.py +115 -0
  127. overcode/implementations.py +325 -204
  128. overcode/job_launcher.py +5 -8
  129. overcode/job_manager.py +15 -130
  130. overcode/journey.py +58 -24
  131. overcode/json_store.py +331 -0
  132. overcode/keymap.py +15 -4
  133. overcode/launch_settings.py +175 -0
  134. overcode/launcher.py +645 -906
  135. overcode/layout_migration.py +299 -0
  136. overcode/ledger.py +910 -0
  137. overcode/lifecycle.py +321 -34
  138. overcode/mentor.py +2 -2
  139. overcode/model_metadata.py +2 -2
  140. overcode/monitor_daemon.py +1375 -997
  141. overcode/monitor_daemon_core.py +69 -87
  142. overcode/monitor_daemon_state.py +200 -25
  143. overcode/month_files.py +144 -0
  144. overcode/name_source.py +105 -0
  145. overcode/namer.py +1274 -0
  146. overcode/naming_lab.py +399 -0
  147. overcode/naming_policy.py +446 -0
  148. overcode/opencode_plugin/overcode-telemetry-v2/index.js +31 -5
  149. overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +156 -7
  150. overcode/opencode_plugin/overcode-telemetry.js +89 -187
  151. overcode/overagent.py +5 -5
  152. overcode/pane_capture_gate.py +0 -4
  153. overcode/paths.py +365 -0
  154. overcode/pid_utils.py +178 -183
  155. overcode/presence_logger.py +29 -32
  156. overcode/price_table.py +555 -0
  157. overcode/pricing.py +203 -27
  158. overcode/process_resources.py +54 -1
  159. overcode/protocols.py +50 -76
  160. overcode/quota_calibration.py +461 -0
  161. overcode/quota_engine.py +1061 -0
  162. overcode/quota_model.py +272 -0
  163. overcode/rename.py +345 -0
  164. overcode/sandbox_detect.py +0 -12
  165. overcode/session_archive.py +580 -0
  166. overcode/session_identity.py +258 -0
  167. overcode/session_manager.py +543 -1146
  168. overcode/settings.py +305 -375
  169. overcode/sister_controller.py +1 -1
  170. overcode/sister_poller.py +65 -2
  171. overcode/skill_library.py +4 -4
  172. overcode/spend_recompute.py +337 -0
  173. overcode/ssh_provisioner.py +6 -6
  174. overcode/standing_instructions.py +14 -79
  175. overcode/stat_gate.py +3 -3
  176. overcode/stats_reader.py +59 -53
  177. overcode/status_constants.py +173 -60
  178. overcode/status_detector.py +67 -14
  179. overcode/status_detector_factory.py +83 -17
  180. overcode/status_history.py +31 -210
  181. overcode/status_patterns.py +136 -67
  182. overcode/status_theme.py +348 -0
  183. overcode/statusline_tap.py +209 -0
  184. overcode/summarizer_client.py +25 -5
  185. overcode/summarizer_component.py +3 -14
  186. overcode/summarizer_prompts.py +129 -4
  187. overcode/summary_columns.py +117 -2236
  188. overcode/summary_groups.py +0 -20
  189. overcode/supervisor_daemon.py +218 -122
  190. overcode/supervisor_daemon_core.py +8 -40
  191. overcode/swarm.py +1034 -0
  192. overcode/swarm_files.py +232 -0
  193. overcode/swarm_history.py +445 -0
  194. overcode/swarm_publisher.py +116 -0
  195. overcode/swarm_view.py +406 -0
  196. overcode/testing/renderer.py +0 -41
  197. overcode/testing/tmux_driver.py +1 -1
  198. overcode/testing/tui_eye.py +17 -2
  199. overcode/testing/tui_eye_skill.md +11 -8
  200. overcode/time_context.py +42 -44
  201. overcode/tmux_manager.py +151 -309
  202. overcode/tmux_utils.py +146 -53
  203. overcode/transcript_index.py +522 -64
  204. overcode/trip.py +175 -0
  205. overcode/tui.py +1051 -3498
  206. overcode/tui.tcss +51 -18
  207. overcode/tui_actions/__init__.py +2 -0
  208. overcode/tui_actions/activity.py +3 -3
  209. overcode/tui_actions/claude_bg.py +113 -0
  210. overcode/tui_actions/command_bar.py +392 -0
  211. overcode/tui_actions/daemon.py +46 -10
  212. overcode/tui_actions/diagnostics.py +107 -0
  213. overcode/tui_actions/dialogs.py +1052 -0
  214. overcode/tui_actions/input.py +2 -2
  215. overcode/tui_actions/jobs.py +256 -0
  216. overcode/tui_actions/mentor.py +0 -1
  217. overcode/tui_actions/navigation.py +7 -3
  218. overcode/tui_actions/overagent.py +9 -16
  219. overcode/tui_actions/session.py +328 -9
  220. overcode/tui_actions/sisters.py +351 -0
  221. overcode/tui_actions/swarm.py +619 -0
  222. overcode/tui_actions/view.py +97 -173
  223. overcode/tui_actions/view_control.py +5 -5
  224. overcode/tui_engine.py +293 -0
  225. overcode/tui_engine_link.py +359 -0
  226. overcode/tui_helpers.py +66 -251
  227. overcode/tui_logic.py +11 -514
  228. overcode/tui_render.py +3 -378
  229. overcode/tui_split.py +557 -0
  230. overcode/tui_timers.py +149 -0
  231. overcode/tui_widgets/__init__.py +13 -3
  232. overcode/tui_widgets/agent_visibility_modal.py +141 -0
  233. overcode/tui_widgets/auto_naming_modal.py +259 -0
  234. overcode/tui_widgets/billing_code_modal.py +207 -0
  235. overcode/tui_widgets/billing_codes_modal.py +892 -0
  236. overcode/tui_widgets/column_header.py +24 -2
  237. overcode/tui_widgets/command_bar.py +27 -3
  238. overcode/tui_widgets/daemon_panel.py +89 -46
  239. overcode/tui_widgets/daemon_status_bar.py +179 -135
  240. overcode/tui_widgets/help_overlay.py +66 -35
  241. overcode/tui_widgets/naming_lab.py +869 -0
  242. overcode/tui_widgets/new_agent_defaults_modal.py +84 -10
  243. overcode/tui_widgets/new_agent_modal.py +114 -3
  244. overcode/tui_widgets/rename_agent_modal.py +72 -16
  245. overcode/tui_widgets/session_summary.py +526 -248
  246. overcode/tui_widgets/sister_selection_modal.py +1 -1
  247. overcode/tui_widgets/status_colours_modal.py +180 -0
  248. overcode/tui_widgets/status_timeline.py +184 -23
  249. overcode/tui_widgets/swarm_rows.py +209 -0
  250. overcode/tui_widgets/tui_log_panel.py +7 -28
  251. overcode/upgrade_consent.py +124 -0
  252. overcode/usage_monitor.py +50 -8
  253. overcode/view_control.py +4 -2
  254. overcode/web_api.py +55 -595
  255. overcode/web_control_api.py +30 -61
  256. overcode/web_server.py +91 -200
  257. overcode/web_server_runner.py +31 -11
  258. overcode/worker_guard.py +0 -7
  259. overcode/wrapper.py +6 -5
  260. {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/METADATA +10 -13
  261. overcode-0.6.2.dist-info/RECORD +344 -0
  262. {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/WHEEL +1 -1
  263. {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/entry_points.txt +1 -1
  264. overcode/duration.py +0 -28
  265. overcode/interfaces.py +0 -49
  266. overcode/logging_config.py +0 -190
  267. overcode/mocks.py +0 -209
  268. overcode/supervisor_layout.sh +0 -55
  269. overcode/tui_widgets/fullscreen_preview.py +0 -137
  270. overcode/web/__init__.py +0 -0
  271. overcode/web/templates/analytics.html +0 -1089
  272. overcode/web/templates/dashboard.html +0 -553
  273. overcode/web_chartjs.py +0 -32
  274. overcode/web_templates.py +0 -23
  275. overcode-0.5.8.dist-info/RECORD +0 -247
  276. {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/licenses/LICENSE +0 -0
  277. {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/top_level.txt +0 -0
overcode/activity_log.py CHANGED
@@ -45,7 +45,7 @@ from datetime import date, datetime, timedelta
45
45
  from pathlib import Path
46
46
  from typing import Any, Iterator, Optional
47
47
 
48
- from .settings import get_overcode_dir
48
+ from .paths import overcode_dir
49
49
 
50
50
  try:
51
51
  import fcntl
@@ -66,7 +66,7 @@ REDACTED_CHAR = "<c>"
66
66
 
67
67
 
68
68
  def get_activity_dir() -> Path:
69
- return get_overcode_dir() / "activity"
69
+ return overcode_dir() / "activity"
70
70
 
71
71
 
72
72
  # Days of full records kept; older days survive only as their day summary.
@@ -227,7 +227,7 @@ def record_cli_invocation(argv: list[str], groups: frozenset[str] | set[str] = f
227
227
  internal: frozenset[str] | set[str] = frozenset()) -> None:
228
228
  """One `cli` record per overcode command: the command path and flag names, never values.
229
229
 
230
- Calls made by an agent overcode launched (OVERCODE_SESSION_NAME set)
230
+ Calls made by an agent overcode launched (``agent_identity.in_agent``)
231
231
  are only counted (count_agent_cli): they are never the person's own use,
232
232
  and an agent polling the CLI would otherwise fill the log.
233
233
  """
@@ -236,7 +236,9 @@ def record_cli_invocation(argv: list[str], groups: frozenset[str] | set[str] = f
236
236
  if (not cmd or words[0] in _INTERNAL_COMMANDS or words[0] in internal
237
237
  or any(w.startswith("_") for w in words) or not recording_enabled()):
238
238
  return
239
- if os.environ.get("OVERCODE_SESSION_NAME"):
239
+ from .agent_identity import in_agent
240
+
241
+ if in_agent():
240
242
  count_agent_cli(cmd)
241
243
  return
242
244
  flags = sorted({a.split("=", 1)[0] for a in argv if a.startswith("-")})
@@ -0,0 +1,422 @@
1
+ """Archived agents' own files, one gzip member per agent in a day bundle.
2
+
3
+ An agent's directory (``agents/<session id>/``: hook state, event log,
4
+ report, episode log) lives only while its record is in sessions.json. When
5
+ the record is archived the directory is packed into one gzip member
6
+ appended to its tmux session's bundle for the day,
7
+ ``sessions/<tmux>/bundles/<YYYY-MM-DD>.agents.gz``, and removed. The
8
+ archive record carries a pointer to the member,
9
+ ``{"day", "off", "len"}``, so one agent is read back by a seek, and
10
+ ``zcat <day>.agents.gz`` streams the whole day.
11
+
12
+ The order is write-ahead (``SessionManager.commit_pending``): pack and
13
+ fsync the member; append the archive record (with the pointer) and fsync;
14
+ take the record out of sessions.json; remove the directory. A crash at any
15
+ point leaves a duplicate (an unreferenced member, an archived record still
16
+ live, a directory with no record), never a loss: the next pass, or the
17
+ orphan sweep, finishes the job.
18
+
19
+ Each member is one JSON line::
20
+
21
+ {"sid", "name", "tmux", "parent", "archived_at",
22
+ "files": {"hook_state.json": {...}, "report.json": {...},
23
+ "hook_events.jsonl": "...", "episodes.jsonl": "..."}}
24
+
25
+ The episode log is packed from its tail (EPISODES_TAIL_BYTES), so a
26
+ member stays small whatever the agent did. Bundles are kept for ever
27
+ unless ``agent_history.retention_days`` / ``max_bytes`` are set
28
+ (:func:`prune`).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import gzip
34
+ import json
35
+ import os
36
+ import shutil
37
+ import zlib
38
+ from datetime import datetime, timedelta
39
+ from pathlib import Path
40
+ from typing import Callable, Dict, Iterator, List, Optional, Tuple
41
+
42
+ from .paths import (
43
+ AGENT_DIR_FILES,
44
+ RESERVED_DIR_NAMES,
45
+ agent_dir,
46
+ agents_dir,
47
+ bundle_path,
48
+ sessions_root,
49
+ )
50
+
51
+ try:
52
+ import fcntl
53
+ HAS_FCNTL = True
54
+ except ImportError: # pragma: no cover - Windows
55
+ HAS_FCNTL = False
56
+
57
+ # What a member keeps of each file. JSON files are parsed (an unreadable one
58
+ # is kept as text); logs are kept as text, cut to their last N bytes on a
59
+ # line boundary. The lock, the heartbeat stamp, temp files and the rotated
60
+ # episode log (nothing reads episodes yet) are left out.
61
+ JSON_FILES = ("hook_state.json", "report.json", "report.previous.json",
62
+ AGENT_DIR_FILES["namer"])
63
+ EPISODES_TAIL_BYTES = 256 * 1024
64
+ HOOK_EVENTS_TAIL_BYTES = 128 * 1024
65
+ OTHER_TAIL_BYTES = 64 * 1024
66
+ TEXT_FILES = {
67
+ AGENT_DIR_FILES["episodes"]: EPISODES_TAIL_BYTES,
68
+ AGENT_DIR_FILES["hook_events"]: HOOK_EVENTS_TAIL_BYTES,
69
+ AGENT_DIR_FILES["rename_notice"]: OTHER_TAIL_BYTES,
70
+ # What the namer read (the naming lab reruns advice on archived agents)
71
+ AGENT_DIR_FILES["brief"]: OTHER_TAIL_BYTES,
72
+ AGENT_DIR_FILES["prompts"]: OTHER_TAIL_BYTES,
73
+ AGENT_DIR_FILES["child_notice"]: OTHER_TAIL_BYTES,
74
+ }
75
+
76
+ # An agent directory is an orphan (its record gone) only once it has been
77
+ # still this long: a launch makes its record first and its directory just
78
+ # after, and a late hook may recreate a removed one.
79
+ ORPHAN_MIN_AGE_SECONDS = 600.0
80
+
81
+ BUNDLE_SUFFIX = ".agents.gz"
82
+
83
+ # Bytes fed to the decoder at a time when walking a day's members
84
+ _CHUNK = 64 * 1024
85
+
86
+ # Day files whose tail this process has checked, by path: (inode, size it
87
+ # left the file at). Before the first append of a run, and after anyone
88
+ # else appended, the tail is walked and a torn last member cut off.
89
+ _checked_tails: Dict[str, Tuple[int, int]] = {}
90
+
91
+
92
+ def _tail_text(path: Path, limit: int) -> Optional[str]:
93
+ """The last ``limit`` bytes of a text file, from a line start; None if unreadable."""
94
+ try:
95
+ with open(path, "rb") as f:
96
+ size = f.seek(0, os.SEEK_END)
97
+ start = max(0, size - limit)
98
+ f.seek(start)
99
+ data = f.read()
100
+ except OSError:
101
+ return None
102
+ if start > 0:
103
+ newline = data.find(b"\n")
104
+ data = data[newline + 1:] if newline != -1 else b""
105
+ return data.decode("utf-8", errors="replace")
106
+
107
+
108
+ def collect_files(directory: Path, *, keep_hook_events: bool = True) -> Dict[str, object]:
109
+ """What a member keeps of an agent directory (see the module docstring)."""
110
+ files: Dict[str, object] = {}
111
+ for name in JSON_FILES:
112
+ text = _tail_text(directory / name, OTHER_TAIL_BYTES)
113
+ if text is None:
114
+ continue
115
+ try:
116
+ files[name] = json.loads(text)
117
+ except ValueError:
118
+ files[name] = text
119
+ for name, limit in TEXT_FILES.items():
120
+ if name == AGENT_DIR_FILES["hook_events"] and not keep_hook_events:
121
+ continue
122
+ text = _tail_text(directory / name, limit)
123
+ if text is not None:
124
+ files[name] = text
125
+ return files
126
+
127
+
128
+ def _members(data: bytes) -> Iterator[Tuple[int, bytes]]:
129
+ """(end offset, decompressed bytes) of each complete gzip member, in order.
130
+
131
+ Fed in chunks, so a day of many members is walked in one pass over its
132
+ bytes; stops at the first torn or damaged member.
133
+ """
134
+ view = memoryview(data)
135
+ pos = 0
136
+ size = len(data)
137
+ while pos < size:
138
+ decoder = zlib.decompressobj(wbits=31)
139
+ out = []
140
+ cursor = pos
141
+ try:
142
+ while not decoder.eof and cursor < size:
143
+ chunk = view[cursor:cursor + _CHUNK]
144
+ cursor += len(chunk)
145
+ out.append(decoder.decompress(chunk))
146
+ except zlib.error:
147
+ return
148
+ if not decoder.eof:
149
+ return # torn: the member never finished
150
+ end = cursor - len(decoder.unused_data)
151
+ yield end, b"".join(out)
152
+ pos = end
153
+
154
+
155
+ def _good_end(f) -> int:
156
+ """Offset after the last complete gzip member of an open bundle."""
157
+ f.seek(0)
158
+ end = 0
159
+ for end, _ in _members(f.read()):
160
+ pass
161
+ return end
162
+
163
+
164
+ def _append_member(path: Path, member: bytes) -> Tuple[int, int]:
165
+ """Append one gzip member to a day file under its lock; fsynced. Returns (offset, length)."""
166
+ path.parent.mkdir(parents=True, exist_ok=True)
167
+ with open(path, "a+b") as f:
168
+ if HAS_FCNTL:
169
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
170
+ try:
171
+ st = os.fstat(f.fileno())
172
+ key = str(path)
173
+ if _checked_tails.get(key) != (st.st_ino, st.st_size):
174
+ good = _good_end(f)
175
+ if good < st.st_size:
176
+ f.truncate(good)
177
+ offset = os.fstat(f.fileno()).st_size
178
+ f.write(member)
179
+ f.flush()
180
+ os.fsync(f.fileno())
181
+ _checked_tails[key] = (st.st_ino, offset + len(member))
182
+ return offset, len(member)
183
+ finally:
184
+ if HAS_FCNTL:
185
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
186
+
187
+
188
+ def pack_agent_dir(
189
+ tmux_session: str,
190
+ session_id: str,
191
+ meta: Optional[dict] = None,
192
+ *,
193
+ now: Optional[datetime] = None,
194
+ keep_hook_events: Optional[bool] = None,
195
+ ) -> Optional[dict]:
196
+ """Pack agent ``session_id``'s directory into today's bundle.
197
+
198
+ Returns the pointer ``{"day", "off", "len"}``, or None when the agent
199
+ has no directory (nothing to keep). ``meta`` (name, parent, ...) is
200
+ stored with the files. The directory is left in place: removing it is
201
+ the caller's last step, once the archive record is durable.
202
+ """
203
+ directory = agent_dir(tmux_session, session_id)
204
+ if not directory.is_dir():
205
+ return None
206
+ if keep_hook_events is None:
207
+ from .config import get_agent_history_config
208
+ keep_hook_events = get_agent_history_config()["keep_hook_events"]
209
+ now = now or datetime.now()
210
+ record = {
211
+ "sid": session_id,
212
+ "tmux": tmux_session,
213
+ **(meta or {}),
214
+ "archived_at": now.isoformat(),
215
+ "files": collect_files(directory, keep_hook_events=keep_hook_events),
216
+ }
217
+ line = (json.dumps(record, default=str) + "\n").encode("utf-8")
218
+ member = gzip.compress(line, mtime=0)
219
+ day = now.strftime("%Y-%m-%d")
220
+ offset, length = _append_member(bundle_path(tmux_session, day), member)
221
+ return {"day": day, "off": offset, "len": length}
222
+
223
+
224
+ def read_agent(tmux_session: str, pointer: dict) -> Optional[dict]:
225
+ """The packed agent a pointer names, or None (expired day, bad pointer)."""
226
+ try:
227
+ path = bundle_path(tmux_session, str(pointer["day"]))
228
+ with open(path, "rb") as f:
229
+ f.seek(int(pointer["off"]))
230
+ data = f.read(int(pointer["len"]))
231
+ record = json.loads(gzip.decompress(data))
232
+ except (KeyError, TypeError, ValueError, OSError, EOFError, zlib.error):
233
+ return None
234
+ return record if isinstance(record, dict) else None
235
+
236
+
237
+ def iter_day(tmux_session: str, day: str) -> Iterator[dict]:
238
+ """Every agent packed on ``day``, in order; stops at a torn or damaged member."""
239
+ try:
240
+ data = bundle_path(tmux_session, day).read_bytes()
241
+ except OSError:
242
+ return
243
+ for _, raw in _members(data):
244
+ try:
245
+ record = json.loads(raw)
246
+ except ValueError:
247
+ continue
248
+ if isinstance(record, dict):
249
+ yield record
250
+
251
+
252
+ def bundle_day_files() -> List[Tuple[str, str, Path]]:
253
+ """(tmux session, day, path) of every bundle, oldest day first.
254
+
255
+ One scandir of the sessions root, and one of each session's
256
+ ``bundles/``: a day file per day kept.
257
+ """
258
+ found: List[Tuple[str, str, Path]] = []
259
+ try:
260
+ sessions = [e for e in os.scandir(sessions_root())
261
+ if e.is_dir() and e.name not in RESERVED_DIR_NAMES]
262
+ except OSError:
263
+ return found
264
+ for entry in sessions:
265
+ try:
266
+ days = list(os.scandir(Path(entry.path) / "bundles"))
267
+ except OSError:
268
+ continue
269
+ for day in days:
270
+ if day.name.endswith(BUNDLE_SUFFIX):
271
+ found.append((entry.name, day.name[: -len(BUNDLE_SUFFIX)], Path(day.path)))
272
+ found.sort(key=lambda t: (t[1], t[0]))
273
+ return found
274
+
275
+
276
+ def prune(
277
+ *,
278
+ retention_days: float = 0,
279
+ max_bytes: int = 0,
280
+ now: Optional[datetime] = None,
281
+ limit: Optional[int] = None,
282
+ ) -> List[Path]:
283
+ """Delete bundle days past the retention, oldest first; returns what went.
284
+
285
+ Off by default (both limits 0: bundles are kept for ever). Days older
286
+ than ``retention_days`` go, across every tmux session; then, while the
287
+ total is over ``max_bytes``, the oldest days. Today's day file is never
288
+ deleted. ``limit`` bounds the unlinks (a CLI pass).
289
+ """
290
+ if not retention_days and not max_bytes:
291
+ return []
292
+ now = now or datetime.now()
293
+ today = now.strftime("%Y-%m-%d")
294
+ days = bundle_day_files()
295
+ removed: List[Path] = []
296
+
297
+ def remove(path: Path) -> bool:
298
+ if limit is not None and len(removed) >= limit:
299
+ return False
300
+ try:
301
+ path.unlink()
302
+ except OSError:
303
+ return True
304
+ removed.append(path)
305
+ return True
306
+
307
+ kept = []
308
+ if retention_days:
309
+ cutoff = (now - timedelta(days=retention_days)).strftime("%Y-%m-%d")
310
+ for item in days:
311
+ if item[1] < cutoff and item[1] != today:
312
+ if not remove(item[2]):
313
+ return removed
314
+ else:
315
+ kept.append(item)
316
+ else:
317
+ kept = days
318
+ if max_bytes:
319
+ sizes = []
320
+ for item in kept:
321
+ try:
322
+ sizes.append((item, item[2].stat().st_size))
323
+ except OSError:
324
+ pass
325
+ total = sum(size for _, size in sizes)
326
+ for item, size in sizes:
327
+ if total <= max_bytes:
328
+ break
329
+ if item[1] == today:
330
+ continue
331
+ if not remove(item[2]):
332
+ return removed
333
+ total -= size
334
+ return removed
335
+
336
+
337
+ def sweep_orphans(
338
+ tmux_session: str,
339
+ live_ids: Callable[[], set],
340
+ *,
341
+ archived: Callable[[set], set],
342
+ record: Callable[[str, dict], None],
343
+ attach: Optional[Callable[[str, dict], None]] = None,
344
+ now: Optional[float] = None,
345
+ min_age: float = ORPHAN_MIN_AGE_SECONDS,
346
+ limit: int = 200,
347
+ ) -> Dict[str, int]:
348
+ """Clear agent directories whose record has gone; bounded, never raises.
349
+
350
+ One scandir of ``agents/`` (the size of the live fleet once orphans
351
+ are gone). A directory is an orphan when its session id is not in a
352
+ sessions.json snapshot taken after the scandir (``live_ids``) and it
353
+ has not changed for ``min_age`` seconds. The archive is asked once,
354
+ for all of them, which are archived already (``archived(ids)`` returns
355
+ those): they are removed, unless their record has no bundle (packing
356
+ failed when they were archived: ``archived`` then returns a mapping
357
+ ``{sid: has_bundle}``), when they are packed and ``attach(sid,
358
+ pointer)`` gives the record its bundle first. Any other is packed
359
+ first and ``record(sid, pointer)`` writes its archive record before
360
+ the directory goes. At most ``limit`` orphans per call.
361
+
362
+ Returns counts: ``removed`` (archived already), ``packed``, ``kept``
363
+ (too recent, or packing failed).
364
+ """
365
+ import time
366
+
367
+ counts = {"removed": 0, "packed": 0, "kept": 0}
368
+ now = time.time() if now is None else now
369
+ try:
370
+ entries = [e for e in os.scandir(agents_dir(tmux_session)) if e.is_dir()]
371
+ except OSError:
372
+ return counts
373
+ if not entries:
374
+ return counts
375
+ try:
376
+ live = live_ids()
377
+ except Exception:
378
+ return counts # can't tell what is live: touch nothing
379
+ orphans = []
380
+ for entry in entries:
381
+ if len(orphans) >= limit:
382
+ break
383
+ if entry.name in live:
384
+ continue
385
+ try:
386
+ if now - entry.stat().st_mtime < min_age:
387
+ counts["kept"] += 1
388
+ continue
389
+ except OSError:
390
+ continue
391
+ orphans.append(entry)
392
+ if not orphans:
393
+ return counts
394
+ try:
395
+ already = archived({e.name for e in orphans})
396
+ except Exception:
397
+ return counts
398
+ for entry in orphans:
399
+ sid = entry.name
400
+ try:
401
+ if sid in already:
402
+ unpacked = isinstance(already, dict) and not already[sid]
403
+ if not unpacked or attach is None:
404
+ shutil.rmtree(entry.path, ignore_errors=True)
405
+ counts["removed"] += 1
406
+ continue
407
+ # Archived, but its packing failed then: pack it now
408
+ pointer = pack_agent_dir(tmux_session, sid, {"retry": True})
409
+ if pointer is None:
410
+ continue
411
+ attach(sid, pointer)
412
+ else:
413
+ pointer = pack_agent_dir(tmux_session, sid, {"orphan": True})
414
+ if pointer is None:
415
+ continue
416
+ record(sid, pointer)
417
+ except Exception:
418
+ counts["kept"] += 1
419
+ continue
420
+ shutil.rmtree(entry.path, ignore_errors=True)
421
+ counts["packed"] += 1
422
+ return counts
@@ -0,0 +1,78 @@
1
+ """An agent's own directory: made at launch, reset on relaunch, removed at the end.
2
+
3
+ Every agent keeps its per-agent files (hook state and event log, report,
4
+ episode log, rename notice) in ``<session dir>/agents/<session id>/``
5
+ (``paths.agent_dir``, quality review item 35). The directory exists from
6
+ launch until the agent is archived, when its files are packed into the day
7
+ bundle (``agent_bundle``) and the directory is removed.
8
+
9
+ Its existence is the signal readers go by (``paths.agent_path``): an agent
10
+ with a directory has its files there; one without was launched before
11
+ per-agent directories and still writes name-keyed files until it is
12
+ relaunched.
13
+ """
14
+
15
+ import os
16
+ import shutil
17
+
18
+ from .paths import agent_dir, agent_file
19
+
20
+ # Where a relaunch moves the report the previous run filed, so the new run
21
+ # does not settle from it (kept for whoever wants to read it).
22
+ PREVIOUS_REPORT = "report.previous.json"
23
+
24
+
25
+ def prepare_agent_dir(tmux_session: str, session_id: str, *, reset: bool = False) -> None:
26
+ """Make the agent's directory before its process starts; never raises.
27
+
28
+ ``reset`` is a relaunch of the same agent (restart, revive): its hook
29
+ state goes and its report is moved aside, so the new process is not
30
+ read through what the old one left: a Stop that settles a revived
31
+ child at once, or a report that marks it done.
32
+ """
33
+ directory = agent_dir(tmux_session, session_id)
34
+ try:
35
+ directory.mkdir(parents=True, exist_ok=True)
36
+ except OSError:
37
+ return
38
+ if not reset:
39
+ return
40
+ try:
41
+ agent_file(tmux_session, session_id, "hook_state").unlink(missing_ok=True)
42
+ except OSError:
43
+ pass
44
+ report = agent_file(tmux_session, session_id, "report")
45
+ try:
46
+ os.replace(report, directory / PREVIOUS_REPORT)
47
+ except OSError:
48
+ pass # none filed
49
+
50
+
51
+ def remove_agent_dir(tmux_session: str, session_id: str) -> None:
52
+ """Remove the agent's directory and everything in it; never raises."""
53
+ shutil.rmtree(agent_dir(tmux_session, session_id), ignore_errors=True)
54
+
55
+
56
+ # The namer's view of the task (#542, auto-naming §4): the launch prompt's
57
+ # first bytes, written once at launch and never moved.
58
+ BRIEF_BYTES = 2048
59
+
60
+
61
+ def write_brief(tmux_session: str, session_id: str, prompt: str) -> None:
62
+ """Keep the first ``BRIEF_BYTES`` of the launch prompt as the agent's brief; never raises."""
63
+ if not prompt or not prompt.strip():
64
+ return
65
+ text = prompt.encode("utf-8")[:BRIEF_BYTES].decode("utf-8", "ignore")
66
+ try:
67
+ agent_dir(tmux_session, session_id).mkdir(parents=True, exist_ok=True)
68
+ agent_file(tmux_session, session_id, "brief").write_text(text, encoding="utf-8")
69
+ except OSError:
70
+ pass # the namer falls back to the prompts and the pane
71
+
72
+
73
+ def read_brief(tmux_session: str, session_id: str) -> str:
74
+ """The agent's brief, or "" when it has none."""
75
+ try:
76
+ return agent_file(tmux_session, session_id, "brief").read_text(encoding="utf-8")
77
+ except (OSError, UnicodeDecodeError):
78
+ return ""
@@ -0,0 +1,50 @@
1
+ """
2
+ Which agent this process runs in (auto-naming §1).
3
+
4
+ An agent's process carries ``OVERCODE_SESSION_ID`` and
5
+ ``OVERCODE_SESSION_NAME`` from its launch. A rename does not restart it, so
6
+ the env name is only the name it was launched under: the agent is the id.
7
+ The name identifies it only for an agent launched without an id (pre-0.6),
8
+ and then follows renames (``resolve_session_name``).
9
+ """
10
+
11
+ import os
12
+ from typing import Mapping, Optional
13
+
14
+
15
+ def env_agent_id(env: Optional[Mapping[str, str]] = None) -> Optional[str]:
16
+ """This process's agent id (``OVERCODE_SESSION_ID``), or None."""
17
+ return (os.environ if env is None else env).get("OVERCODE_SESSION_ID") or None
18
+
19
+
20
+ def env_agent_name(env: Optional[Mapping[str, str]] = None) -> Optional[str]:
21
+ """The name this process's agent was launched under, or None."""
22
+ return (os.environ if env is None else env).get("OVERCODE_SESSION_NAME") or None
23
+
24
+
25
+ def in_agent(env: Optional[Mapping[str, str]] = None) -> bool:
26
+ """True inside an agent overcode launched (either variable set)."""
27
+ return bool(env_agent_id(env) or env_agent_name(env))
28
+
29
+
30
+ def current_agent(sessions, env: Optional[Mapping[str, str]] = None):
31
+ """This process's agent record from ``sessions`` (a SessionManager), or None.
32
+
33
+ By id; by the launch name, following renames, only with no id. An id
34
+ with no record is no agent: its launch name may belong to another by now.
35
+ """
36
+ sid = env_agent_id(env)
37
+ if sid:
38
+ return sessions.get_session(sid)
39
+ name = env_agent_name(env)
40
+ return sessions.resolve_session_name(name) if name else None
41
+
42
+
43
+ def find_in_daemon_state(state: dict, session_id: Optional[str],
44
+ name: Optional[str]) -> Optional[dict]:
45
+ """An agent's entry in a parsed ``monitor_daemon_state.json``: by id, else by name."""
46
+ key, value = ("session_id", session_id) if session_id else ("name", name)
47
+ for s in state.get("sessions", []):
48
+ if s.get(key) == value:
49
+ return s
50
+ return None