overcode 0.4.2__py3-none-any.whl → 0.5.3__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 (84) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/backends/__init__.py +176 -0
  3. overcode/backends/base.py +231 -0
  4. overcode/backends/claude_code.py +289 -0
  5. overcode/backends/codex.py +615 -0
  6. overcode/backends/codex_stats.py +508 -0
  7. overcode/backends/grok.py +881 -0
  8. overcode/backends/grok_stats.py +400 -0
  9. overcode/backends/hermes.py +866 -0
  10. overcode/backends/hermes_stats.py +613 -0
  11. overcode/backends/opencode.py +745 -0
  12. overcode/backends/opencode2.py +356 -0
  13. overcode/backends/opencode2_patterns.py +227 -0
  14. overcode/backends/opencode2_plugin_install.py +142 -0
  15. overcode/backends/opencode2_stats.py +583 -0
  16. overcode/backends/opencode_stats.py +712 -0
  17. overcode/bundled_skills.py +4 -4
  18. overcode/claude_pid.py +1 -1
  19. overcode/cli/__init__.py +3 -2
  20. overcode/cli/_shared.py +7 -1
  21. overcode/cli/agent.py +118 -51
  22. overcode/cli/config.py +16 -0
  23. overcode/cli/daemon.py +1 -1
  24. overcode/cli/doctor.py +58 -12
  25. overcode/cli/hooks.py +56 -2
  26. overcode/cli/models.py +124 -0
  27. overcode/cli/parallelism.py +2 -2
  28. overcode/cli/split.py +101 -18
  29. overcode/config.py +101 -1
  30. overcode/daemon_claude_skill.md +63 -3
  31. overcode/daemon_logging.py +46 -0
  32. overcode/data/model_metadata.json +982 -0
  33. overcode/dependency_check.py +61 -9
  34. overcode/doctor.py +106 -40
  35. overcode/exceptions.py +15 -7
  36. overcode/hermes_plugin/__init__.py +327 -0
  37. overcode/hermes_plugin/plugin.yaml +22 -0
  38. overcode/history_reader.py +347 -43
  39. overcode/hook_handler.py +296 -2
  40. overcode/hook_status_detector.py +62 -22
  41. overcode/launcher.py +285 -230
  42. overcode/model_metadata.py +556 -0
  43. overcode/monitor_daemon.py +238 -151
  44. overcode/monitor_daemon_state.py +12 -5
  45. overcode/opencode_plugin/overcode-telemetry-v2/index.js +21 -0
  46. overcode/opencode_plugin/overcode-telemetry-v2/overcode-telemetry-core.mjs +611 -0
  47. overcode/opencode_plugin/overcode-telemetry-v2/tui.js +103 -0
  48. overcode/opencode_plugin/overcode-telemetry.js +481 -0
  49. overcode/pricing.py +66 -2
  50. overcode/process_resources.py +2 -2
  51. overcode/session_manager.py +167 -35
  52. overcode/settings.py +46 -1
  53. overcode/sister_poller.py +24 -0
  54. overcode/stats_reader.py +334 -0
  55. overcode/status_constants.py +1 -1
  56. overcode/status_detector.py +34 -44
  57. overcode/status_detector_factory.py +98 -20
  58. overcode/status_history.py +320 -0
  59. overcode/status_patterns.py +233 -54
  60. overcode/summary_columns.py +84 -14
  61. overcode/supervisor_daemon.py +1 -0
  62. overcode/supervisor_daemon_core.py +10 -1
  63. overcode/tui.py +163 -37
  64. overcode/tui_actions/input.py +2 -2
  65. overcode/tui_actions/session.py +70 -16
  66. overcode/tui_actions/view.py +8 -0
  67. overcode/tui_logic.py +5 -5
  68. overcode/tui_widgets/agent_select_modal.py +5 -5
  69. overcode/tui_widgets/daemon_panel.py +11 -11
  70. overcode/tui_widgets/help_overlay.py +1 -1
  71. overcode/tui_widgets/new_agent_defaults_modal.py +53 -9
  72. overcode/tui_widgets/new_agent_modal.py +30 -13
  73. overcode/tui_widgets/session_summary.py +15 -5
  74. overcode/tui_widgets/tui_log_panel.py +8 -8
  75. overcode/web_api.py +13 -7
  76. overcode/web_control_api.py +67 -28
  77. overcode/wrapper.py +107 -27
  78. {overcode-0.4.2.dist-info → overcode-0.5.3.dist-info}/METADATA +17 -4
  79. overcode-0.5.3.dist-info/RECORD +154 -0
  80. {overcode-0.4.2.dist-info → overcode-0.5.3.dist-info}/WHEEL +1 -1
  81. overcode-0.4.2.dist-info/RECORD +0 -129
  82. {overcode-0.4.2.dist-info → overcode-0.5.3.dist-info}/entry_points.txt +0 -0
  83. {overcode-0.4.2.dist-info → overcode-0.5.3.dist-info}/licenses/LICENSE +0 -0
  84. {overcode-0.4.2.dist-info → overcode-0.5.3.dist-info}/top_level.txt +0 -0
overcode/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Overcode - A supervisor for managing multiple Claude Code instances.
2
+ Overcode - A supervisor for managing multiple coding agents (Claude Code, opencode).
3
3
  """
4
4
 
5
5
  from pathlib import Path
@@ -0,0 +1,176 @@
1
+ """Agent backend registry.
2
+
3
+ ``get_backend(name)`` resolves a ``Session.backend`` discriminator to the
4
+ adapter that owns that CLI's argv grammar and gestures.
5
+ """
6
+
7
+ from typing import Any, Dict, List
8
+
9
+ from .base import (
10
+ AgentBackend,
11
+ BackendCapability,
12
+ DialogRule,
13
+ KeyPress,
14
+ LaunchSpec,
15
+ supports,
16
+ )
17
+ from .claude_code import ClaudeCodeBackend
18
+ from .codex import CodexBackend
19
+ from .grok import GrokBackend
20
+ from .hermes import HermesBackend
21
+ from .opencode import OpencodeBackend
22
+ from .opencode2 import Opencode2Backend
23
+
24
+ DEFAULT_BACKEND = "claude-code"
25
+
26
+ _BACKENDS: Dict[str, AgentBackend] = {
27
+ ClaudeCodeBackend.name: ClaudeCodeBackend(),
28
+ OpencodeBackend.name: OpencodeBackend(),
29
+ Opencode2Backend.name: Opencode2Backend(),
30
+ CodexBackend.name: CodexBackend(),
31
+ GrokBackend.name: GrokBackend(),
32
+ HermesBackend.name: HermesBackend(),
33
+ }
34
+
35
+
36
+ class UnknownBackendError(ValueError):
37
+ """Raised when a Session names a backend overcode doesn't have."""
38
+
39
+
40
+ def get_backend(name: str = DEFAULT_BACKEND) -> AgentBackend:
41
+ """Resolve a backend by name.
42
+
43
+ Empty/None resolves to the default so pre-backend sessions and
44
+ partially-populated dicts keep working.
45
+ """
46
+ key = name or DEFAULT_BACKEND
47
+ try:
48
+ return _BACKENDS[key]
49
+ except KeyError:
50
+ known = ", ".join(sorted(_BACKENDS))
51
+ raise UnknownBackendError(
52
+ f"Unknown agent backend '{key}'. Known backends: {known}"
53
+ ) from None
54
+
55
+
56
+ def list_backends() -> List[str]:
57
+ """Names of every registered backend."""
58
+ return sorted(_BACKENDS)
59
+
60
+
61
+ def register_backend(backend: AgentBackend) -> None:
62
+ """Register (or replace) a backend. Used by tests to install doubles."""
63
+ _BACKENDS[backend.name] = backend
64
+ _invalidate_derived_caches()
65
+
66
+
67
+ def unregister_backend(name: str) -> None:
68
+ """Remove a backend. Silent when it isn't registered."""
69
+ _BACKENDS.pop(name, None)
70
+ _invalidate_derived_caches()
71
+
72
+
73
+ def _invalidate_derived_caches() -> None:
74
+ """Drop per-backend objects other modules cache by backend name."""
75
+ from ..stats_reader import clear_reader_cache
76
+ from ..status_patterns import clear_patterns_cache
77
+ clear_reader_cache()
78
+ clear_patterns_cache()
79
+
80
+
81
+ def session_backend_name(session: Any) -> str:
82
+ """Backend name recorded on a session, defaulting for legacy sessions."""
83
+ name = getattr(session, "backend", None)
84
+ if isinstance(name, str) and name:
85
+ return name
86
+ return DEFAULT_BACKEND
87
+
88
+
89
+ def capability_names(capabilities: BackendCapability) -> List[str]:
90
+ """Serialize a capability flag set to sorted member names.
91
+
92
+ This is the wire form: ``SessionDaemonState.backend_capabilities`` and
93
+ therefore the sister protocol carry these strings, so a newer sister can
94
+ tell an older TUI what its backends can do.
95
+ """
96
+ return sorted(
97
+ member.name
98
+ for member in BackendCapability
99
+ if member.name and member.value and (capabilities & member)
100
+ )
101
+
102
+
103
+ def capabilities_from_names(names: Any) -> BackendCapability:
104
+ """Parse serialized capability names back into a flag set.
105
+
106
+ Unknown names are ignored: a sister running a newer overcode may report
107
+ capabilities this build has never heard of.
108
+ """
109
+ result = BackendCapability.NONE
110
+ if not isinstance(names, (list, tuple, set)):
111
+ return result
112
+ for name in names:
113
+ member = getattr(BackendCapability, name, None) if isinstance(name, str) else None
114
+ if isinstance(member, BackendCapability):
115
+ result |= member
116
+ return result
117
+
118
+
119
+ def session_capabilities(session: Any) -> BackendCapability:
120
+ """Capabilities of the backend behind a session.
121
+
122
+ Remote (sister) agents are answered from the capability list their own
123
+ daemon published, so a sister can run a backend this host doesn't have.
124
+ Sisters predating Phase 6 report nothing; they are assumed to be
125
+ claude-code with the full capability set (design §3, consequence 5).
126
+ """
127
+ # Strict bool check: callers hand us duck-typed stand-ins whose every
128
+ # attribute is truthy, and misreading one as remote would silently swap
129
+ # in another host's capability answer.
130
+ is_remote = getattr(session, "is_remote", False)
131
+ if isinstance(is_remote, bool) and is_remote:
132
+ remote_state = getattr(session, "remote_daemon_state", None)
133
+ if isinstance(remote_state, dict) and remote_state.get("backend_capabilities"):
134
+ return capabilities_from_names(remote_state["backend_capabilities"])
135
+ return get_backend(DEFAULT_BACKEND).capabilities
136
+ try:
137
+ backend = get_backend(session_backend_name(session))
138
+ except UnknownBackendError:
139
+ return BackendCapability.NONE
140
+ return backend.capabilities
141
+
142
+
143
+ def session_supports(session: Any, capability: BackendCapability) -> bool:
144
+ """True when the session's backend declares ``capability``.
145
+
146
+ An unknown backend name answers False — an adapter overcode doesn't
147
+ have can't be assumed to support anything.
148
+ """
149
+ return bool(session_capabilities(session) & capability)
150
+
151
+
152
+ __all__ = [
153
+ "AgentBackend",
154
+ "BackendCapability",
155
+ "ClaudeCodeBackend",
156
+ "CodexBackend",
157
+ "GrokBackend",
158
+ "HermesBackend",
159
+ "OpencodeBackend",
160
+ "Opencode2Backend",
161
+ "DEFAULT_BACKEND",
162
+ "DialogRule",
163
+ "KeyPress",
164
+ "LaunchSpec",
165
+ "UnknownBackendError",
166
+ "capabilities_from_names",
167
+ "capability_names",
168
+ "get_backend",
169
+ "list_backends",
170
+ "register_backend",
171
+ "session_backend_name",
172
+ "session_capabilities",
173
+ "session_supports",
174
+ "unregister_backend",
175
+ "supports",
176
+ ]
@@ -0,0 +1,231 @@
1
+ """Backend seam: the protocol every agent CLI adapter implements.
2
+
3
+ An ``AgentBackend`` owns everything that is specific to one agent CLI —
4
+ argv grammar, env vars, startup-dialog handshake, key gestures — so the
5
+ rest of overcode can stay backend-neutral. See
6
+ ``docs/design/agent-agnostic-backends-opencode.md`` §2.1.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Flag, auto
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Dict,
14
+ List,
15
+ Optional,
16
+ Protocol,
17
+ Sequence,
18
+ Set,
19
+ Tuple,
20
+ Type,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from ..stats_reader import StatsReader
25
+ from ..status_patterns import StatusPatterns
26
+
27
+
28
+ class BackendCapability(Flag):
29
+ """What an agent CLI can do. Used to gate UI actions and telemetry."""
30
+
31
+ NONE = 0
32
+ RESUME = auto() # relaunch continuing a prior conversation
33
+ FORK = auto() # branch a conversation into a new agent
34
+ SESSION_ID_PRESCRIPTION = auto() # overcode chooses the session id up front
35
+ HOOK_EVENTS = auto() # push telemetry (hook-state files) available
36
+ TRANSCRIPT_STATS = auto() # tokens/cost/context readable from disk
37
+ PERMISSION_INJECTION = auto() # per-launch permission allowlist
38
+ AGENT_INJECTION = auto() # launch-time persona/agent flag (--agent) is honored
39
+ SKILLS = auto() # skills/persona file discovery
40
+ SANDBOX_PROBE = auto()
41
+ SUBSCRIPTION_USAGE = auto()
42
+ AGENT_TEAMS = auto()
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class KeyPress:
47
+ """One tmux send-keys gesture, with the pause that must follow it."""
48
+
49
+ keys: str
50
+ enter: bool = False
51
+ delay_after: float = 0.0
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class DialogRule:
56
+ """A startup dialog to dismiss while waiting for the input prompt.
57
+
58
+ ``marker`` is matched against captured pane content; ``presses`` are
59
+ sent in order, then the poller sleeps ``settle_seconds``.
60
+ """
61
+
62
+ marker: str
63
+ presses: Sequence[KeyPress]
64
+ settle_seconds: float = 1.0
65
+
66
+
67
+ @dataclass
68
+ class LaunchSpec:
69
+ """Everything a backend needs to render one launch invocation.
70
+
71
+ Carries the launch-time knobs recorded on a ``Session`` plus the
72
+ backend-neutral OVERCODE_* identity fields, so a backend can inject
73
+ them into its own env/config if it needs to.
74
+ """
75
+
76
+ # Session identity (exported into the child process env)
77
+ name: str = ""
78
+ session_id: str = ""
79
+ tmux_session: str = ""
80
+ parent_session_id: Optional[str] = None
81
+ parent_name: Optional[str] = None
82
+
83
+ # Conversation selection
84
+ resume_session_id: Optional[str] = None
85
+ fork: bool = False
86
+ prescribed_session_id: Optional[str] = None
87
+
88
+ # Permissions
89
+ permissiveness_mode: Optional[str] = None
90
+ skip_permissions: bool = False
91
+ dangerously_skip_permissions: bool = False
92
+ include_punchy_perms: bool = False
93
+
94
+ # Agent configuration
95
+ model: Optional[str] = None
96
+ agent: Optional[str] = None
97
+ allowed_tools: Optional[str] = None
98
+ extra_args: List[str] = field(default_factory=list)
99
+ agent_teams: bool = False
100
+ provider: str = "web"
101
+
102
+ # Execution environment
103
+ start_directory: Optional[str] = None
104
+ wrapper: Optional[str] = None
105
+ mock_scenario: Optional[str] = None
106
+
107
+
108
+ class AgentBackend(Protocol):
109
+ """Adapter for one agent CLI."""
110
+
111
+ name: str # "claude-code" | "opencode" | "opencode2" | "codex" | "grok" | "hermes"
112
+ display_name: str
113
+ binary: str # for dependency_check + doctor process matching
114
+ version_args: Sequence[str]
115
+ install_hint: str
116
+ process_basenames: Sequence[str]
117
+ not_found_error: Type[Exception]
118
+ capabilities: BackendCapability
119
+
120
+ # True when the backend's fork grammar takes an explicit new session id
121
+ # (--session-id alongside --fork-session) AND the prescribed id is
122
+ # authoritative for the forked session; False when the CLI mints its own
123
+ # fork id and discovery must fill it in. Only meaningful alongside
124
+ # SESSION_ID_PRESCRIPTION — launcher.py's fork branch mints a fresh uuid
125
+ # to bind eagerly only when both are true. Both grok (Phase 3) and Claude
126
+ # Code (#466, 2026-08-28) declare this True: Claude Code was originally
127
+ # assumed to mint its own, different fork id, but a live check of
128
+ # `claude --resume <id> --fork-session --session-id <new>` found the
129
+ # prescribed id IS honored — the CLI writes the fork's transcript under
130
+ # the prescribed uuid. That wrong assumption was #466's root cause:
131
+ # without eager binding, a fork's id was discovered via directory+time
132
+ # matching, which is ambiguous whenever another agent shares the same
133
+ # start_directory (the fork's record could bind to a sibling's id
134
+ # instead of its own). (The default backends don't literally inherit
135
+ # this Protocol, so launcher.py reads it via
136
+ # ``getattr(backend, "fork_prescribes_new_session_id", False)`` — this
137
+ # default documents the fallback, it isn't inherited automatically.)
138
+ fork_prescribes_new_session_id: bool = False
139
+
140
+ # Optional. Substrings that identify the agent's live process by argv when
141
+ # its basename is not distinctive — hermes runs as Hermes's own venv
142
+ # python (`.../venv/bin/python .../hermes-agent/hermes --cli`), so
143
+ # matching ``process_basenames`` alone would have to match "python".
144
+ # ``doctor.find_agent_process`` reads this via ``getattr`` and accepts a
145
+ # process whose basename is in ``process_basenames`` OR whose argv
146
+ # contains any marker. Same "not inherited from the Protocol" caveat as
147
+ # fork_prescribes_new_session_id above.
148
+ process_argv_markers: Sequence[str] = ()
149
+
150
+ def build_command(self, spec: LaunchSpec) -> List[str]: ...
151
+
152
+ def prepare_launch(self, spec: LaunchSpec) -> None:
153
+ """Side effects the CLI needs in place before the process starts.
154
+
155
+ Called once per launch/restart/revive/fork, after the binary check and
156
+ before the shell line is sent. Claude Code needs nothing here — its
157
+ telemetry rides on ``--settings`` — but opencode installs its
158
+ telemetry plugin into the project directory. Must be idempotent and
159
+ must never raise: a failure costs telemetry, not the launch.
160
+ """
161
+ ...
162
+
163
+ def env_prefix(self, spec: LaunchSpec) -> Dict[str, str]: ...
164
+
165
+ def resume_args(self, session_id: str, fork: bool) -> List[str]: ...
166
+
167
+ def graceful_exit_keys(self) -> List[KeyPress]: ...
168
+
169
+ def clear_conversation_keys(self) -> List[KeyPress]: ...
170
+
171
+ def approve_keys(self) -> List[KeyPress]: ...
172
+
173
+ def reject_keys(self) -> List[KeyPress]: ...
174
+
175
+ def startup_dialog_rules(self) -> List[DialogRule]: ...
176
+
177
+ def prompt_ready_chars(self) -> Set[str]: ...
178
+
179
+ def prompt_ready_line(self, line: str) -> bool:
180
+ """Optional. Is this (ANSI-stripped, whitespace-trimmed) pane line the
181
+ live, empty input prompt?
182
+
183
+ ``launcher._wait_for_prompt`` consults this via ``getattr`` before
184
+ falling back to an exact match against ``prompt_ready_chars()``. The
185
+ exact-set form can't express a prompt that is never drawn bare —
186
+ hermes always follows its glyph with a rotating placeholder hint —
187
+ so a backend with that shape implements this instead.
188
+ """
189
+ ...
190
+
191
+ def uninstall_telemetry(self, project_dir: Optional[str] = None) -> Tuple[bool, str]:
192
+ """Optional. Remove this backend's on-disk telemetry footprint.
193
+
194
+ Backs ``overcode hooks uninstall-backend <name>``. Returns ``(ok,
195
+ message)``: ``ok`` False means the footprint was found but *not*
196
+ removed (not overcode-managed, or ``project_dir`` was needed and
197
+ missing) and the CLI exits non-zero. A backend whose telemetry
198
+ leaves nothing on disk (Claude Code's ``--settings``, codex's ``-c``
199
+ overrides) returns ``(True, "nothing installed on disk …")``.
200
+ """
201
+ ...
202
+
203
+ def doctor_findings(self) -> List[str]:
204
+ """Optional. Fleet-level warnings for ``overcode doctor`` — version
205
+ outside the tested range, missing auth, stats-store schema drift.
206
+ Only called when the fleet actually has an agent on this backend.
207
+ """
208
+ ...
209
+
210
+ def status_patterns(self) -> "StatusPatterns": ...
211
+
212
+ def make_stats_reader(self) -> "StatsReader": ...
213
+
214
+ def health_verdict(self, argv: str) -> Optional[Tuple[str, str]]:
215
+ """Doctor's "is observability wired up?" answer for a live process.
216
+
217
+ Returns ``(verdict, details)`` from the ``doctor.VERDICT_*``
218
+ vocabulary, given the agent process's full argv.
219
+
220
+ A backend whose telemetry leaves no trace on the command line may
221
+ also define an *optional* ``refine_health_verdict(session, verdict,
222
+ details) -> (verdict, details)``. ``doctor`` calls it via ``getattr``
223
+ with the session in hand — opencode uses it to check for its
224
+ telemetry plugin in the project directory.
225
+ """
226
+ ...
227
+
228
+
229
+ def supports(backend: AgentBackend, capability: BackendCapability) -> bool:
230
+ """True when ``backend`` declares ``capability``."""
231
+ return bool(backend.capabilities & capability)