overcode 0.4.2__py3-none-any.whl → 0.5.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/__init__.py +1 -1
- overcode/backends/__init__.py +170 -0
- overcode/backends/base.py +189 -0
- overcode/backends/claude_code.py +270 -0
- overcode/backends/codex.py +590 -0
- overcode/backends/codex_stats.py +508 -0
- overcode/backends/grok.py +857 -0
- overcode/backends/grok_stats.py +400 -0
- overcode/backends/opencode.py +719 -0
- overcode/backends/opencode_stats.py +641 -0
- overcode/bundled_skills.py +4 -4
- overcode/claude_pid.py +1 -1
- overcode/cli/__init__.py +2 -2
- overcode/cli/_shared.py +1 -1
- overcode/cli/agent.py +118 -51
- overcode/cli/daemon.py +1 -1
- overcode/cli/doctor.py +65 -12
- overcode/cli/hooks.py +75 -2
- overcode/cli/parallelism.py +2 -2
- overcode/cli/split.py +101 -18
- overcode/config.py +74 -1
- overcode/daemon_claude_skill.md +40 -3
- overcode/daemon_logging.py +46 -0
- overcode/dependency_check.py +61 -9
- overcode/doctor.py +91 -40
- overcode/exceptions.py +15 -7
- overcode/history_reader.py +330 -43
- overcode/hook_handler.py +296 -2
- overcode/hook_status_detector.py +62 -22
- overcode/launcher.py +279 -230
- overcode/monitor_daemon.py +131 -150
- overcode/monitor_daemon_state.py +12 -5
- overcode/opencode_plugin/overcode-telemetry.js +481 -0
- overcode/pricing.py +32 -0
- overcode/process_resources.py +2 -2
- overcode/session_manager.py +167 -35
- overcode/settings.py +32 -0
- overcode/sister_poller.py +24 -0
- overcode/stats_reader.py +334 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +34 -44
- overcode/status_detector_factory.py +98 -20
- overcode/status_history.py +303 -0
- overcode/status_patterns.py +233 -54
- overcode/summary_columns.py +82 -14
- overcode/supervisor_daemon.py +1 -0
- overcode/supervisor_daemon_core.py +10 -1
- overcode/tui.py +92 -31
- overcode/tui_actions/input.py +2 -2
- overcode/tui_actions/session.py +70 -16
- overcode/tui_logic.py +5 -5
- overcode/tui_widgets/agent_select_modal.py +5 -5
- overcode/tui_widgets/daemon_panel.py +11 -11
- overcode/tui_widgets/help_overlay.py +1 -1
- overcode/tui_widgets/new_agent_defaults_modal.py +53 -9
- overcode/tui_widgets/new_agent_modal.py +30 -13
- overcode/tui_widgets/session_summary.py +15 -5
- overcode/tui_widgets/tui_log_panel.py +8 -8
- overcode/web_api.py +13 -7
- overcode/web_control_api.py +67 -28
- overcode/wrapper.py +95 -27
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/METADATA +16 -4
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/RECORD +67 -56
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/WHEEL +1 -1
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/entry_points.txt +0 -0
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/top_level.txt +0 -0
overcode/__init__.py
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
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 .opencode import OpencodeBackend
|
|
21
|
+
|
|
22
|
+
DEFAULT_BACKEND = "claude-code"
|
|
23
|
+
|
|
24
|
+
_BACKENDS: Dict[str, AgentBackend] = {
|
|
25
|
+
ClaudeCodeBackend.name: ClaudeCodeBackend(),
|
|
26
|
+
OpencodeBackend.name: OpencodeBackend(),
|
|
27
|
+
CodexBackend.name: CodexBackend(),
|
|
28
|
+
GrokBackend.name: GrokBackend(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnknownBackendError(ValueError):
|
|
33
|
+
"""Raised when a Session names a backend overcode doesn't have."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_backend(name: str = DEFAULT_BACKEND) -> AgentBackend:
|
|
37
|
+
"""Resolve a backend by name.
|
|
38
|
+
|
|
39
|
+
Empty/None resolves to the default so pre-backend sessions and
|
|
40
|
+
partially-populated dicts keep working.
|
|
41
|
+
"""
|
|
42
|
+
key = name or DEFAULT_BACKEND
|
|
43
|
+
try:
|
|
44
|
+
return _BACKENDS[key]
|
|
45
|
+
except KeyError:
|
|
46
|
+
known = ", ".join(sorted(_BACKENDS))
|
|
47
|
+
raise UnknownBackendError(
|
|
48
|
+
f"Unknown agent backend '{key}'. Known backends: {known}"
|
|
49
|
+
) from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_backends() -> List[str]:
|
|
53
|
+
"""Names of every registered backend."""
|
|
54
|
+
return sorted(_BACKENDS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_backend(backend: AgentBackend) -> None:
|
|
58
|
+
"""Register (or replace) a backend. Used by tests to install doubles."""
|
|
59
|
+
_BACKENDS[backend.name] = backend
|
|
60
|
+
_invalidate_derived_caches()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unregister_backend(name: str) -> None:
|
|
64
|
+
"""Remove a backend. Silent when it isn't registered."""
|
|
65
|
+
_BACKENDS.pop(name, None)
|
|
66
|
+
_invalidate_derived_caches()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _invalidate_derived_caches() -> None:
|
|
70
|
+
"""Drop per-backend objects other modules cache by backend name."""
|
|
71
|
+
from ..stats_reader import clear_reader_cache
|
|
72
|
+
from ..status_patterns import clear_patterns_cache
|
|
73
|
+
clear_reader_cache()
|
|
74
|
+
clear_patterns_cache()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def session_backend_name(session: Any) -> str:
|
|
78
|
+
"""Backend name recorded on a session, defaulting for legacy sessions."""
|
|
79
|
+
name = getattr(session, "backend", None)
|
|
80
|
+
if isinstance(name, str) and name:
|
|
81
|
+
return name
|
|
82
|
+
return DEFAULT_BACKEND
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def capability_names(capabilities: BackendCapability) -> List[str]:
|
|
86
|
+
"""Serialize a capability flag set to sorted member names.
|
|
87
|
+
|
|
88
|
+
This is the wire form: ``SessionDaemonState.backend_capabilities`` and
|
|
89
|
+
therefore the sister protocol carry these strings, so a newer sister can
|
|
90
|
+
tell an older TUI what its backends can do.
|
|
91
|
+
"""
|
|
92
|
+
return sorted(
|
|
93
|
+
member.name
|
|
94
|
+
for member in BackendCapability
|
|
95
|
+
if member.name and member.value and (capabilities & member)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def capabilities_from_names(names: Any) -> BackendCapability:
|
|
100
|
+
"""Parse serialized capability names back into a flag set.
|
|
101
|
+
|
|
102
|
+
Unknown names are ignored: a sister running a newer overcode may report
|
|
103
|
+
capabilities this build has never heard of.
|
|
104
|
+
"""
|
|
105
|
+
result = BackendCapability.NONE
|
|
106
|
+
if not isinstance(names, (list, tuple, set)):
|
|
107
|
+
return result
|
|
108
|
+
for name in names:
|
|
109
|
+
member = getattr(BackendCapability, name, None) if isinstance(name, str) else None
|
|
110
|
+
if isinstance(member, BackendCapability):
|
|
111
|
+
result |= member
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def session_capabilities(session: Any) -> BackendCapability:
|
|
116
|
+
"""Capabilities of the backend behind a session.
|
|
117
|
+
|
|
118
|
+
Remote (sister) agents are answered from the capability list their own
|
|
119
|
+
daemon published, so a sister can run a backend this host doesn't have.
|
|
120
|
+
Sisters predating Phase 6 report nothing; they are assumed to be
|
|
121
|
+
claude-code with the full capability set (design §3, consequence 5).
|
|
122
|
+
"""
|
|
123
|
+
# Strict bool check: callers hand us duck-typed stand-ins whose every
|
|
124
|
+
# attribute is truthy, and misreading one as remote would silently swap
|
|
125
|
+
# in another host's capability answer.
|
|
126
|
+
is_remote = getattr(session, "is_remote", False)
|
|
127
|
+
if isinstance(is_remote, bool) and is_remote:
|
|
128
|
+
remote_state = getattr(session, "remote_daemon_state", None)
|
|
129
|
+
if isinstance(remote_state, dict) and remote_state.get("backend_capabilities"):
|
|
130
|
+
return capabilities_from_names(remote_state["backend_capabilities"])
|
|
131
|
+
return get_backend(DEFAULT_BACKEND).capabilities
|
|
132
|
+
try:
|
|
133
|
+
backend = get_backend(session_backend_name(session))
|
|
134
|
+
except UnknownBackendError:
|
|
135
|
+
return BackendCapability.NONE
|
|
136
|
+
return backend.capabilities
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def session_supports(session: Any, capability: BackendCapability) -> bool:
|
|
140
|
+
"""True when the session's backend declares ``capability``.
|
|
141
|
+
|
|
142
|
+
An unknown backend name answers False — an adapter overcode doesn't
|
|
143
|
+
have can't be assumed to support anything.
|
|
144
|
+
"""
|
|
145
|
+
return bool(session_capabilities(session) & capability)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = [
|
|
149
|
+
"AgentBackend",
|
|
150
|
+
"BackendCapability",
|
|
151
|
+
"ClaudeCodeBackend",
|
|
152
|
+
"CodexBackend",
|
|
153
|
+
"GrokBackend",
|
|
154
|
+
"OpencodeBackend",
|
|
155
|
+
"DEFAULT_BACKEND",
|
|
156
|
+
"DialogRule",
|
|
157
|
+
"KeyPress",
|
|
158
|
+
"LaunchSpec",
|
|
159
|
+
"UnknownBackendError",
|
|
160
|
+
"capabilities_from_names",
|
|
161
|
+
"capability_names",
|
|
162
|
+
"get_backend",
|
|
163
|
+
"list_backends",
|
|
164
|
+
"register_backend",
|
|
165
|
+
"session_backend_name",
|
|
166
|
+
"session_capabilities",
|
|
167
|
+
"session_supports",
|
|
168
|
+
"unregister_backend",
|
|
169
|
+
"supports",
|
|
170
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
SKILLS = auto() # skills/persona file discovery
|
|
39
|
+
SANDBOX_PROBE = auto()
|
|
40
|
+
SUBSCRIPTION_USAGE = auto()
|
|
41
|
+
AGENT_TEAMS = auto()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class KeyPress:
|
|
46
|
+
"""One tmux send-keys gesture, with the pause that must follow it."""
|
|
47
|
+
|
|
48
|
+
keys: str
|
|
49
|
+
enter: bool = False
|
|
50
|
+
delay_after: float = 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class DialogRule:
|
|
55
|
+
"""A startup dialog to dismiss while waiting for the input prompt.
|
|
56
|
+
|
|
57
|
+
``marker`` is matched against captured pane content; ``presses`` are
|
|
58
|
+
sent in order, then the poller sleeps ``settle_seconds``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
marker: str
|
|
62
|
+
presses: Sequence[KeyPress]
|
|
63
|
+
settle_seconds: float = 1.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LaunchSpec:
|
|
68
|
+
"""Everything a backend needs to render one launch invocation.
|
|
69
|
+
|
|
70
|
+
Carries the launch-time knobs recorded on a ``Session`` plus the
|
|
71
|
+
backend-neutral OVERCODE_* identity fields, so a backend can inject
|
|
72
|
+
them into its own env/config if it needs to.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Session identity (exported into the child process env)
|
|
76
|
+
name: str = ""
|
|
77
|
+
session_id: str = ""
|
|
78
|
+
tmux_session: str = ""
|
|
79
|
+
parent_session_id: Optional[str] = None
|
|
80
|
+
parent_name: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
# Conversation selection
|
|
83
|
+
resume_session_id: Optional[str] = None
|
|
84
|
+
fork: bool = False
|
|
85
|
+
prescribed_session_id: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
# Permissions
|
|
88
|
+
permissiveness_mode: Optional[str] = None
|
|
89
|
+
skip_permissions: bool = False
|
|
90
|
+
dangerously_skip_permissions: bool = False
|
|
91
|
+
include_punchy_perms: bool = False
|
|
92
|
+
|
|
93
|
+
# Agent configuration
|
|
94
|
+
model: Optional[str] = None
|
|
95
|
+
agent: Optional[str] = None
|
|
96
|
+
allowed_tools: Optional[str] = None
|
|
97
|
+
extra_args: List[str] = field(default_factory=list)
|
|
98
|
+
agent_teams: bool = False
|
|
99
|
+
provider: str = "web"
|
|
100
|
+
|
|
101
|
+
# Execution environment
|
|
102
|
+
start_directory: Optional[str] = None
|
|
103
|
+
wrapper: Optional[str] = None
|
|
104
|
+
mock_scenario: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AgentBackend(Protocol):
|
|
108
|
+
"""Adapter for one agent CLI."""
|
|
109
|
+
|
|
110
|
+
name: str # "claude-code" | "opencode" | "codex" | "grok"
|
|
111
|
+
display_name: str
|
|
112
|
+
binary: str # for dependency_check + doctor process matching
|
|
113
|
+
version_args: Sequence[str]
|
|
114
|
+
install_hint: str
|
|
115
|
+
process_basenames: Sequence[str]
|
|
116
|
+
not_found_error: Type[Exception]
|
|
117
|
+
capabilities: BackendCapability
|
|
118
|
+
|
|
119
|
+
# True when the backend's fork grammar takes an explicit new session id
|
|
120
|
+
# (--session-id alongside --fork-session) AND the prescribed id is
|
|
121
|
+
# authoritative for the forked session; False when the CLI mints its own
|
|
122
|
+
# fork id and discovery must fill it in. Only meaningful alongside
|
|
123
|
+
# SESSION_ID_PRESCRIPTION — launcher.py's fork branch mints a fresh uuid
|
|
124
|
+
# to bind eagerly only when both are true. Both grok (Phase 3) and Claude
|
|
125
|
+
# Code (#466, 2026-08-28) declare this True: Claude Code was originally
|
|
126
|
+
# assumed to mint its own, different fork id, but a live check of
|
|
127
|
+
# `claude --resume <id> --fork-session --session-id <new>` found the
|
|
128
|
+
# prescribed id IS honored — the CLI writes the fork's transcript under
|
|
129
|
+
# the prescribed uuid. That wrong assumption was #466's root cause:
|
|
130
|
+
# without eager binding, a fork's id was discovered via directory+time
|
|
131
|
+
# matching, which is ambiguous whenever another agent shares the same
|
|
132
|
+
# start_directory (the fork's record could bind to a sibling's id
|
|
133
|
+
# instead of its own). (The default backends don't literally inherit
|
|
134
|
+
# this Protocol, so launcher.py reads it via
|
|
135
|
+
# ``getattr(backend, "fork_prescribes_new_session_id", False)`` — this
|
|
136
|
+
# default documents the fallback, it isn't inherited automatically.)
|
|
137
|
+
fork_prescribes_new_session_id: bool = False
|
|
138
|
+
|
|
139
|
+
def build_command(self, spec: LaunchSpec) -> List[str]: ...
|
|
140
|
+
|
|
141
|
+
def prepare_launch(self, spec: LaunchSpec) -> None:
|
|
142
|
+
"""Side effects the CLI needs in place before the process starts.
|
|
143
|
+
|
|
144
|
+
Called once per launch/restart/revive/fork, after the binary check and
|
|
145
|
+
before the shell line is sent. Claude Code needs nothing here — its
|
|
146
|
+
telemetry rides on ``--settings`` — but opencode installs its
|
|
147
|
+
telemetry plugin into the project directory. Must be idempotent and
|
|
148
|
+
must never raise: a failure costs telemetry, not the launch.
|
|
149
|
+
"""
|
|
150
|
+
...
|
|
151
|
+
|
|
152
|
+
def env_prefix(self, spec: LaunchSpec) -> Dict[str, str]: ...
|
|
153
|
+
|
|
154
|
+
def resume_args(self, session_id: str, fork: bool) -> List[str]: ...
|
|
155
|
+
|
|
156
|
+
def graceful_exit_keys(self) -> List[KeyPress]: ...
|
|
157
|
+
|
|
158
|
+
def clear_conversation_keys(self) -> List[KeyPress]: ...
|
|
159
|
+
|
|
160
|
+
def approve_keys(self) -> List[KeyPress]: ...
|
|
161
|
+
|
|
162
|
+
def reject_keys(self) -> List[KeyPress]: ...
|
|
163
|
+
|
|
164
|
+
def startup_dialog_rules(self) -> List[DialogRule]: ...
|
|
165
|
+
|
|
166
|
+
def prompt_ready_chars(self) -> Set[str]: ...
|
|
167
|
+
|
|
168
|
+
def status_patterns(self) -> "StatusPatterns": ...
|
|
169
|
+
|
|
170
|
+
def make_stats_reader(self) -> "StatsReader": ...
|
|
171
|
+
|
|
172
|
+
def health_verdict(self, argv: str) -> Optional[Tuple[str, str]]:
|
|
173
|
+
"""Doctor's "is observability wired up?" answer for a live process.
|
|
174
|
+
|
|
175
|
+
Returns ``(verdict, details)`` from the ``doctor.VERDICT_*``
|
|
176
|
+
vocabulary, given the agent process's full argv.
|
|
177
|
+
|
|
178
|
+
A backend whose telemetry leaves no trace on the command line may
|
|
179
|
+
also define an *optional* ``refine_health_verdict(session, verdict,
|
|
180
|
+
details) -> (verdict, details)``. ``doctor`` calls it via ``getattr``
|
|
181
|
+
with the session in hand — opencode uses it to check for its
|
|
182
|
+
telemetry plugin in the project directory.
|
|
183
|
+
"""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def supports(backend: AgentBackend, capability: BackendCapability) -> bool:
|
|
188
|
+
"""True when ``backend`` declares ``capability``."""
|
|
189
|
+
return bool(backend.capabilities & capability)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Claude Code backend — the argv/env grammar overcode was built around."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
|
|
9
|
+
|
|
10
|
+
from ..exceptions import AgentCliNotFoundError
|
|
11
|
+
from .base import (
|
|
12
|
+
AgentBackend,
|
|
13
|
+
BackendCapability,
|
|
14
|
+
DialogRule,
|
|
15
|
+
KeyPress,
|
|
16
|
+
LaunchSpec,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..stats_reader import StatsReader
|
|
21
|
+
from ..status_patterns import StatusPatterns
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PROMPT_READY_CHARS = {">", "›", "❯"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_overcode_bin() -> str:
|
|
28
|
+
"""Resolve absolute path to the overcode binary.
|
|
29
|
+
|
|
30
|
+
Tries shutil.which first (covers global/pipx installs), then falls
|
|
31
|
+
back to invoking via the current Python interpreter (covers uv run,
|
|
32
|
+
venv-only installs, etc.).
|
|
33
|
+
"""
|
|
34
|
+
which = shutil.which("overcode")
|
|
35
|
+
if which:
|
|
36
|
+
return which
|
|
37
|
+
return f"{sys.executable} -m overcode.cli"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_launch_settings(overcode_bin: str, include_punchy_perms: bool = False) -> dict:
|
|
41
|
+
"""Build the --settings JSON for overcode-launched agents.
|
|
42
|
+
|
|
43
|
+
Includes all overcode hooks (with absolute-path commands) and
|
|
44
|
+
permissions so agents don't depend on user-level settings.json
|
|
45
|
+
containing these entries.
|
|
46
|
+
"""
|
|
47
|
+
from ..hook_handler import OVERCODE_HOOKS
|
|
48
|
+
from ..cli.perms import OVERCODE_SAFE_PERMS, OVERCODE_PUNCHY_PERMS
|
|
49
|
+
|
|
50
|
+
# Build hooks dict: event -> [matcher group]
|
|
51
|
+
hooks: dict[str, list] = {}
|
|
52
|
+
for event, _bare_command in OVERCODE_HOOKS:
|
|
53
|
+
hooks.setdefault(event, []).append({
|
|
54
|
+
"matcher": "",
|
|
55
|
+
"hooks": [{"type": "command", "command": f"{overcode_bin} hook-handler"}],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
perms = list(OVERCODE_SAFE_PERMS)
|
|
59
|
+
if include_punchy_perms:
|
|
60
|
+
perms.extend(OVERCODE_PUNCHY_PERMS)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"hooks": hooks,
|
|
64
|
+
"permissions": {"allow": perms},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClaudeCodeBackend:
|
|
69
|
+
"""Claude Code CLI adapter."""
|
|
70
|
+
|
|
71
|
+
name = "claude-code"
|
|
72
|
+
display_name = "Claude Code"
|
|
73
|
+
binary = "claude"
|
|
74
|
+
version_args = ("--version",)
|
|
75
|
+
install_hint = (
|
|
76
|
+
"Claude Code CLI is required but not found. "
|
|
77
|
+
"Install it from: https://claude.ai/claude-code"
|
|
78
|
+
)
|
|
79
|
+
process_basenames = ("claude",)
|
|
80
|
+
not_found_error = AgentCliNotFoundError
|
|
81
|
+
|
|
82
|
+
# Live-verified 2026-08-28 (#466): `claude --resume <id> --fork-session
|
|
83
|
+
# --session-id <new-uuid>` honors the prescribed id — the fork's
|
|
84
|
+
# transcript is written to `~/.claude/projects/<enc>/<new-uuid>.jsonl`
|
|
85
|
+
# (a full replay of the parent conversation plus the new turn), not a
|
|
86
|
+
# CLI-minted id. This corrects the launcher's prior assumption (see
|
|
87
|
+
# `launcher.py::_send_launch_for_session`) that Claude mints its own,
|
|
88
|
+
# different fork id and must rely on directory+time discovery — that
|
|
89
|
+
# discovery was the root cause of #466 (forking in a shared
|
|
90
|
+
# start_directory bound the wrong sibling's session id). Prescribing
|
|
91
|
+
# and eagerly binding the fork's id, the same treatment grok already
|
|
92
|
+
# gets, sidesteps discovery entirely for Claude forks too.
|
|
93
|
+
fork_prescribes_new_session_id = True
|
|
94
|
+
|
|
95
|
+
capabilities = (
|
|
96
|
+
BackendCapability.RESUME
|
|
97
|
+
| BackendCapability.FORK
|
|
98
|
+
| BackendCapability.SESSION_ID_PRESCRIPTION
|
|
99
|
+
| BackendCapability.HOOK_EVENTS
|
|
100
|
+
| BackendCapability.TRANSCRIPT_STATS
|
|
101
|
+
| BackendCapability.PERMISSION_INJECTION
|
|
102
|
+
| BackendCapability.SKILLS
|
|
103
|
+
| BackendCapability.SANDBOX_PROBE
|
|
104
|
+
| BackendCapability.SUBSCRIPTION_USAGE
|
|
105
|
+
| BackendCapability.AGENT_TEAMS
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def executable(self) -> str:
|
|
109
|
+
"""The binary to invoke, honouring the CLAUDE_COMMAND override.
|
|
110
|
+
|
|
111
|
+
The override is how the e2e mock harness substitutes a fake TUI.
|
|
112
|
+
"""
|
|
113
|
+
return os.environ.get("CLAUDE_COMMAND", "claude")
|
|
114
|
+
|
|
115
|
+
def resume_args(self, session_id: str, fork: bool) -> List[str]:
|
|
116
|
+
args = ["--resume", session_id]
|
|
117
|
+
if fork:
|
|
118
|
+
args.append("--fork-session")
|
|
119
|
+
return args
|
|
120
|
+
|
|
121
|
+
def build_command(self, spec: LaunchSpec) -> List[str]:
|
|
122
|
+
"""Construct the claude CLI argument list.
|
|
123
|
+
|
|
124
|
+
For new launches, ``prescribed_session_id`` pins the Claude session
|
|
125
|
+
ID upfront. For resumes/forks, ``resume_session_id`` (plus ``fork``)
|
|
126
|
+
continues an existing conversation.
|
|
127
|
+
"""
|
|
128
|
+
claude_command = self.executable()
|
|
129
|
+
|
|
130
|
+
if spec.resume_session_id:
|
|
131
|
+
cmd = (
|
|
132
|
+
["claude"]
|
|
133
|
+
if claude_command == "claude"
|
|
134
|
+
else [claude_command]
|
|
135
|
+
)
|
|
136
|
+
cmd.extend(self.resume_args(spec.resume_session_id, spec.fork))
|
|
137
|
+
else:
|
|
138
|
+
cmd = [claude_command]
|
|
139
|
+
|
|
140
|
+
# Prescribe session ID so we know which session file belongs to
|
|
141
|
+
# this agent without needing PID-based discovery (#373). Forks are
|
|
142
|
+
# the one case where a prescribed id rides alongside --resume: the
|
|
143
|
+
# CLI honors --session-id together with --resume/--fork-session
|
|
144
|
+
# (live-verified #466), so a forking launch also gets eager binding
|
|
145
|
+
# instead of ambiguous directory+time discovery. A plain (non-fork)
|
|
146
|
+
# resume ignores any prescribed_session_id — you cannot rename an
|
|
147
|
+
# existing conversation, so build_command must not emit a spurious
|
|
148
|
+
# --session-id there even if the caller passed one (defense in
|
|
149
|
+
# depth; launcher.py never does in practice).
|
|
150
|
+
if spec.prescribed_session_id and (not spec.resume_session_id or spec.fork):
|
|
151
|
+
cmd.extend(["--session-id", spec.prescribed_session_id])
|
|
152
|
+
|
|
153
|
+
# Inject overcode hooks and permissions via --settings so launched
|
|
154
|
+
# agents don't depend on user-level settings.json (#435).
|
|
155
|
+
overcode_bin = _resolve_overcode_bin()
|
|
156
|
+
settings = _build_launch_settings(
|
|
157
|
+
overcode_bin, include_punchy_perms=spec.include_punchy_perms
|
|
158
|
+
)
|
|
159
|
+
cmd.extend(["--settings", json.dumps(settings)])
|
|
160
|
+
|
|
161
|
+
# Permission flags — from explicit args or inherited mode
|
|
162
|
+
if spec.dangerously_skip_permissions or spec.permissiveness_mode == "bypass":
|
|
163
|
+
cmd.append("--dangerously-skip-permissions")
|
|
164
|
+
elif spec.skip_permissions or spec.permissiveness_mode == "permissive":
|
|
165
|
+
cmd.extend(["--permission-mode", "dontAsk"])
|
|
166
|
+
|
|
167
|
+
if spec.model:
|
|
168
|
+
cmd.extend(["--model", spec.model])
|
|
169
|
+
if spec.agent:
|
|
170
|
+
cmd.extend(["--agent", spec.agent])
|
|
171
|
+
if spec.allowed_tools:
|
|
172
|
+
cmd.extend(["--allowedTools", spec.allowed_tools])
|
|
173
|
+
if spec.extra_args:
|
|
174
|
+
for arg in spec.extra_args:
|
|
175
|
+
cmd.extend(shlex.split(arg))
|
|
176
|
+
|
|
177
|
+
return cmd
|
|
178
|
+
|
|
179
|
+
def prepare_launch(self, spec: LaunchSpec) -> None:
|
|
180
|
+
"""Nothing to stage — hooks and permissions ride on ``--settings``.
|
|
181
|
+
|
|
182
|
+
Claude Code is deliberately exempt from ``backend_telemetry`` config
|
|
183
|
+
(``config.get_backend_telemetry_enabled`` always returns True for
|
|
184
|
+
it): its hooks are per-launch ``--settings`` flags on the argv of
|
|
185
|
+
the process overcode itself starts, not a file written to disk, so
|
|
186
|
+
there is no on-disk footprint to opt out of.
|
|
187
|
+
"""
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def env_prefix(self, spec: LaunchSpec) -> Dict[str, str]:
|
|
191
|
+
"""Claude-specific env vars for the launch shell line."""
|
|
192
|
+
env: Dict[str, str] = {}
|
|
193
|
+
if spec.agent_teams:
|
|
194
|
+
env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
|
|
195
|
+
if spec.provider == "bedrock":
|
|
196
|
+
env["CLAUDE_CODE_USE_BEDROCK"] = "1"
|
|
197
|
+
from ..config import get_bedrock_config
|
|
198
|
+
bedrock_cfg = get_bedrock_config()
|
|
199
|
+
env["AWS_REGION"] = bedrock_cfg["region"]
|
|
200
|
+
return env
|
|
201
|
+
|
|
202
|
+
def graceful_exit_keys(self) -> List[KeyPress]:
|
|
203
|
+
# Ctrl-C cancels any in-flight tool call, then /exit reliably
|
|
204
|
+
# terminates the claude process.
|
|
205
|
+
return [
|
|
206
|
+
KeyPress("C-c", enter=False, delay_after=0.5),
|
|
207
|
+
KeyPress("/exit", enter=True),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
def clear_conversation_keys(self) -> List[KeyPress]:
|
|
211
|
+
return [KeyPress("/clear", enter=True)]
|
|
212
|
+
|
|
213
|
+
def approve_keys(self) -> List[KeyPress]:
|
|
214
|
+
return [KeyPress("", enter=True)]
|
|
215
|
+
|
|
216
|
+
def reject_keys(self) -> List[KeyPress]:
|
|
217
|
+
return [KeyPress("Escape", enter=False)]
|
|
218
|
+
|
|
219
|
+
def startup_dialog_rules(self) -> List[DialogRule]:
|
|
220
|
+
return [
|
|
221
|
+
# Trust prompt: default is accept, just Enter
|
|
222
|
+
DialogRule(
|
|
223
|
+
marker="I trust this folder",
|
|
224
|
+
presses=[KeyPress("", enter=True)],
|
|
225
|
+
settle_seconds=1.5,
|
|
226
|
+
),
|
|
227
|
+
# Permissions warning: navigate Down to "Yes, I accept", then Enter
|
|
228
|
+
DialogRule(
|
|
229
|
+
marker="Yes, I accept",
|
|
230
|
+
presses=[
|
|
231
|
+
KeyPress("Down", enter=False, delay_after=0.3),
|
|
232
|
+
KeyPress("", enter=True),
|
|
233
|
+
],
|
|
234
|
+
settle_seconds=2.0,
|
|
235
|
+
),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
def prompt_ready_chars(self) -> Set[str]:
|
|
239
|
+
return PROMPT_READY_CHARS
|
|
240
|
+
|
|
241
|
+
def status_patterns(self) -> "StatusPatterns":
|
|
242
|
+
"""The pane-polling pattern set — overcode's defaults are Claude's."""
|
|
243
|
+
from ..status_patterns import DEFAULT_PATTERNS
|
|
244
|
+
return DEFAULT_PATTERNS
|
|
245
|
+
|
|
246
|
+
def make_stats_reader(self) -> "StatsReader":
|
|
247
|
+
from ..stats_reader import ClaudeStatsReader
|
|
248
|
+
return ClaudeStatsReader()
|
|
249
|
+
|
|
250
|
+
def health_verdict(self, argv: str) -> Tuple[str, str]:
|
|
251
|
+
"""Hooks reach Claude Code only via the injected --settings payload."""
|
|
252
|
+
from ..doctor import VERDICT_MISSING_SETTINGS, VERDICT_OK
|
|
253
|
+
|
|
254
|
+
if "--settings" in argv:
|
|
255
|
+
return VERDICT_OK, "hooks injected via --settings"
|
|
256
|
+
return VERDICT_MISSING_SETTINGS, (
|
|
257
|
+
"claude running without --settings — hooks will not fire. "
|
|
258
|
+
"Relaunch via `overcode restart` to re-inject."
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_backend: Optional[AgentBackend] = None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_claude_backend() -> "ClaudeCodeBackend":
|
|
266
|
+
"""Module-level singleton — backends are stateless."""
|
|
267
|
+
global _backend
|
|
268
|
+
if _backend is None:
|
|
269
|
+
_backend = ClaudeCodeBackend()
|
|
270
|
+
return _backend
|