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.
- overcode/activity_log.py +6 -4
- overcode/agent_bundle.py +422 -0
- overcode/agent_files.py +78 -0
- overcode/agent_identity.py +50 -0
- overcode/agent_io.py +399 -0
- overcode/agent_worktree.py +125 -0
- overcode/backends/__init__.py +2 -0
- overcode/backends/_stats_common.py +234 -0
- overcode/backends/base.py +147 -65
- overcode/backends/claude_code.py +48 -33
- overcode/backends/codex.py +71 -87
- overcode/backends/codex_stats.py +305 -191
- overcode/backends/grok.py +55 -89
- overcode/backends/grok_stats.py +168 -70
- overcode/backends/hermes.py +97 -99
- overcode/backends/hermes_stats.py +92 -136
- overcode/backends/jsonl_tail.py +263 -0
- overcode/backends/opencode.py +90 -75
- overcode/backends/opencode2.py +9 -19
- overcode/backends/opencode2_patterns.py +16 -49
- overcode/backends/opencode2_stats.py +37 -214
- overcode/backends/opencode_stats.py +366 -267
- overcode/backends/shell.py +19 -32
- overcode/bundled_skills.py +26 -8
- overcode/burn.py +159 -0
- overcode/child_lifecycle.py +155 -0
- overcode/child_presets.py +270 -0
- overcode/claude_bg.py +998 -0
- overcode/claude_bg_ledger.py +425 -0
- overcode/claude_config.py +0 -50
- overcode/cli/__init__.py +6 -0
- overcode/cli/_shared.py +12 -3
- overcode/cli/agent.py +558 -159
- overcode/cli/budget.py +518 -1
- overcode/cli/config.py +87 -35
- overcode/cli/daemon.py +5 -3
- overcode/cli/doctor.py +80 -8
- overcode/cli/hooks.py +1 -6
- overcode/cli/jobs.py +9 -5
- overcode/cli/keys.py +1 -1
- overcode/cli/lifecycle.py +14 -4
- overcode/cli/models.py +161 -12
- overcode/cli/monitoring.py +186 -100
- overcode/cli/naming.py +432 -0
- overcode/cli/parallelism.py +1 -1
- overcode/cli/quota.py +128 -0
- overcode/cli/sister.py +23 -17
- overcode/cli/split.py +70 -26
- overcode/cli/tags.py +1 -1
- overcode/cli/trip.py +132 -0
- overcode/cli/wrappers.py +2 -3
- overcode/clock.py +234 -0
- overcode/codes.py +495 -0
- overcode/columns/__init__.py +1 -0
- overcode/columns/column.py +133 -0
- overcode/columns/context.py +379 -0
- overcode/columns/emoji.py +87 -0
- overcode/columns/hover.py +271 -0
- overcode/columns/layout.py +153 -0
- overcode/columns/registry.py +807 -0
- overcode/columns/render_context.py +134 -0
- overcode/columns/render_git.py +80 -0
- overcode/columns/render_identity.py +231 -0
- overcode/columns/render_performance.py +174 -0
- overcode/columns/render_supervision.py +313 -0
- overcode/columns/render_time.py +44 -0
- overcode/columns/render_usage.py +300 -0
- overcode/columns/render_virtual.py +325 -0
- overcode/command_palette.py +96 -13
- overcode/config.py +860 -129
- overcode/conversation_spend.py +619 -0
- overcode/conversation_warning.py +108 -0
- overcode/cost_check.py +163 -0
- overcode/daemon_cadence.py +60 -0
- overcode/daemon_housekeeping.py +457 -0
- overcode/daemon_logging.py +37 -1
- overcode/daemon_presence.py +182 -0
- overcode/daemon_process_probe.py +132 -0
- overcode/daemon_stats_sync.py +491 -0
- overcode/daemon_utils.py +0 -1
- overcode/data/claude_prices.json +274 -0
- overcode/data_export.py +0 -1
- overcode/dependency_check.py +2 -26
- overcode/docs/README.md +1 -0
- overcode/docs/advanced-features.md +65 -71
- overcode/docs/architecture.md +12 -9
- overcode/docs/backends.md +111 -37
- overcode/docs/children.md +276 -0
- overcode/docs/claude-session-files.md +47 -5
- overcode/docs/cli-reference.md +256 -49
- overcode/docs/configuration.md +853 -94
- overcode/docs/design/README.md +7 -0
- overcode/docs/design/agent-backends-codex-grok.md +4 -2
- overcode/docs/design/agent-observability-0.6.1.md +1034 -0
- overcode/docs/design/auto-naming.md +499 -0
- overcode/docs/design/background-shell-classification.md +1 -1
- overcode/docs/design/bakeoffs/zellij-vs-tmux.md +1 -1
- overcode/docs/design/billing-code-ids.md +165 -0
- overcode/docs/design/cost-model-0.6.1.md +499 -0
- overcode/docs/design/engine-0.6.md +305 -0
- overcode/docs/design/opencode-parity.md +239 -0
- overcode/docs/design/opencode-stats-reader-performance.md +64 -5
- overcode/docs/design/quality-review-2026-10.md +378 -0
- overcode/docs/getting-started.md +9 -19
- overcode/docs/release-notes-0.6.0.md +8 -0
- overcode/docs/release-notes-0.6.1.md +49 -0
- overcode/docs/release-notes-0.6.2.md +27 -0
- overcode/docs/releasing.md +54 -0
- overcode/docs/tui-guide.md +611 -46
- overcode/doctor.py +379 -12
- overcode/energy.py +239 -0
- overcode/engine_protocol.py +275 -0
- overcode/engine_recorders.py +209 -0
- overcode/engine_socket.py +524 -0
- overcode/engine_wake.py +264 -0
- overcode/entrypoint.py +26 -12
- overcode/episodes.py +417 -0
- overcode/exceptions.py +15 -126
- overcode/follow_mode.py +40 -52
- overcode/git_context.py +336 -0
- overcode/git_stats.py +103 -0
- overcode/history_reader.py +653 -213
- overcode/hook_dialects.py +226 -0
- overcode/hook_handler.py +657 -310
- overcode/hook_status_detector.py +828 -458
- overcode/idle_stages.py +115 -0
- overcode/implementations.py +325 -204
- overcode/job_launcher.py +5 -8
- overcode/job_manager.py +15 -130
- overcode/journey.py +58 -24
- overcode/json_store.py +331 -0
- overcode/keymap.py +15 -4
- overcode/launch_settings.py +175 -0
- overcode/launcher.py +645 -906
- overcode/layout_migration.py +299 -0
- overcode/ledger.py +910 -0
- overcode/lifecycle.py +321 -34
- overcode/mentor.py +2 -2
- overcode/model_metadata.py +2 -2
- overcode/monitor_daemon.py +1375 -997
- overcode/monitor_daemon_core.py +69 -87
- overcode/monitor_daemon_state.py +200 -25
- overcode/month_files.py +144 -0
- overcode/name_source.py +105 -0
- overcode/namer.py +1274 -0
- overcode/naming_lab.py +399 -0
- overcode/naming_policy.py +446 -0
- overcode/opencode_plugin/overcode-telemetry-v2/index.js +31 -5
- overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +156 -7
- overcode/opencode_plugin/overcode-telemetry.js +89 -187
- overcode/overagent.py +5 -5
- overcode/pane_capture_gate.py +0 -4
- overcode/paths.py +365 -0
- overcode/pid_utils.py +178 -183
- overcode/presence_logger.py +29 -32
- overcode/price_table.py +555 -0
- overcode/pricing.py +203 -27
- overcode/process_resources.py +54 -1
- overcode/protocols.py +50 -76
- overcode/quota_calibration.py +461 -0
- overcode/quota_engine.py +1061 -0
- overcode/quota_model.py +272 -0
- overcode/rename.py +345 -0
- overcode/sandbox_detect.py +0 -12
- overcode/session_archive.py +580 -0
- overcode/session_identity.py +258 -0
- overcode/session_manager.py +543 -1146
- overcode/settings.py +305 -375
- overcode/sister_controller.py +1 -1
- overcode/sister_poller.py +65 -2
- overcode/skill_library.py +4 -4
- overcode/spend_recompute.py +337 -0
- overcode/ssh_provisioner.py +6 -6
- overcode/standing_instructions.py +14 -79
- overcode/stat_gate.py +3 -3
- overcode/stats_reader.py +59 -53
- overcode/status_constants.py +173 -60
- overcode/status_detector.py +67 -14
- overcode/status_detector_factory.py +83 -17
- overcode/status_history.py +31 -210
- overcode/status_patterns.py +136 -67
- overcode/status_theme.py +348 -0
- overcode/statusline_tap.py +209 -0
- overcode/summarizer_client.py +25 -5
- overcode/summarizer_component.py +3 -14
- overcode/summarizer_prompts.py +129 -4
- overcode/summary_columns.py +117 -2236
- overcode/summary_groups.py +0 -20
- overcode/supervisor_daemon.py +218 -122
- overcode/supervisor_daemon_core.py +8 -40
- overcode/swarm.py +1034 -0
- overcode/swarm_files.py +232 -0
- overcode/swarm_history.py +445 -0
- overcode/swarm_publisher.py +116 -0
- overcode/swarm_view.py +406 -0
- overcode/testing/renderer.py +0 -41
- overcode/testing/tmux_driver.py +1 -1
- overcode/testing/tui_eye.py +17 -2
- overcode/testing/tui_eye_skill.md +11 -8
- overcode/time_context.py +42 -44
- overcode/tmux_manager.py +151 -309
- overcode/tmux_utils.py +146 -53
- overcode/transcript_index.py +522 -64
- overcode/trip.py +175 -0
- overcode/tui.py +1051 -3498
- overcode/tui.tcss +51 -18
- overcode/tui_actions/__init__.py +2 -0
- overcode/tui_actions/activity.py +3 -3
- overcode/tui_actions/claude_bg.py +113 -0
- overcode/tui_actions/command_bar.py +392 -0
- overcode/tui_actions/daemon.py +46 -10
- overcode/tui_actions/diagnostics.py +107 -0
- overcode/tui_actions/dialogs.py +1052 -0
- overcode/tui_actions/input.py +2 -2
- overcode/tui_actions/jobs.py +256 -0
- overcode/tui_actions/mentor.py +0 -1
- overcode/tui_actions/navigation.py +7 -3
- overcode/tui_actions/overagent.py +9 -16
- overcode/tui_actions/session.py +328 -9
- overcode/tui_actions/sisters.py +351 -0
- overcode/tui_actions/swarm.py +619 -0
- overcode/tui_actions/view.py +97 -173
- overcode/tui_actions/view_control.py +5 -5
- overcode/tui_engine.py +293 -0
- overcode/tui_engine_link.py +359 -0
- overcode/tui_helpers.py +66 -251
- overcode/tui_logic.py +11 -514
- overcode/tui_render.py +3 -378
- overcode/tui_split.py +557 -0
- overcode/tui_timers.py +149 -0
- overcode/tui_widgets/__init__.py +13 -3
- overcode/tui_widgets/agent_visibility_modal.py +141 -0
- overcode/tui_widgets/auto_naming_modal.py +259 -0
- overcode/tui_widgets/billing_code_modal.py +207 -0
- overcode/tui_widgets/billing_codes_modal.py +892 -0
- overcode/tui_widgets/column_header.py +24 -2
- overcode/tui_widgets/command_bar.py +27 -3
- overcode/tui_widgets/daemon_panel.py +89 -46
- overcode/tui_widgets/daemon_status_bar.py +179 -135
- overcode/tui_widgets/help_overlay.py +66 -35
- overcode/tui_widgets/naming_lab.py +869 -0
- overcode/tui_widgets/new_agent_defaults_modal.py +84 -10
- overcode/tui_widgets/new_agent_modal.py +114 -3
- overcode/tui_widgets/rename_agent_modal.py +72 -16
- overcode/tui_widgets/session_summary.py +526 -248
- overcode/tui_widgets/sister_selection_modal.py +1 -1
- overcode/tui_widgets/status_colours_modal.py +180 -0
- overcode/tui_widgets/status_timeline.py +184 -23
- overcode/tui_widgets/swarm_rows.py +209 -0
- overcode/tui_widgets/tui_log_panel.py +7 -28
- overcode/upgrade_consent.py +124 -0
- overcode/usage_monitor.py +50 -8
- overcode/view_control.py +4 -2
- overcode/web_api.py +55 -595
- overcode/web_control_api.py +30 -61
- overcode/web_server.py +91 -200
- overcode/web_server_runner.py +31 -11
- overcode/worker_guard.py +0 -7
- overcode/wrapper.py +6 -5
- {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/METADATA +10 -13
- overcode-0.6.2.dist-info/RECORD +344 -0
- {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/WHEEL +1 -1
- {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/entry_points.txt +1 -1
- overcode/duration.py +0 -28
- overcode/interfaces.py +0 -49
- overcode/logging_config.py +0 -190
- overcode/mocks.py +0 -209
- overcode/supervisor_layout.sh +0 -55
- overcode/tui_widgets/fullscreen_preview.py +0 -137
- overcode/web/__init__.py +0 -0
- overcode/web/templates/analytics.html +0 -1089
- overcode/web/templates/dashboard.html +0 -553
- overcode/web_chartjs.py +0 -32
- overcode/web_templates.py +0 -23
- overcode-0.5.8.dist-info/RECORD +0 -247
- {overcode-0.5.8.dist-info → overcode-0.6.2.dist-info}/licenses/LICENSE +0 -0
- {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 .
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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("-")})
|
overcode/agent_bundle.py
ADDED
|
@@ -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
|
overcode/agent_files.py
ADDED
|
@@ -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
|