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.
Files changed (67) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/backends/__init__.py +170 -0
  3. overcode/backends/base.py +189 -0
  4. overcode/backends/claude_code.py +270 -0
  5. overcode/backends/codex.py +590 -0
  6. overcode/backends/codex_stats.py +508 -0
  7. overcode/backends/grok.py +857 -0
  8. overcode/backends/grok_stats.py +400 -0
  9. overcode/backends/opencode.py +719 -0
  10. overcode/backends/opencode_stats.py +641 -0
  11. overcode/bundled_skills.py +4 -4
  12. overcode/claude_pid.py +1 -1
  13. overcode/cli/__init__.py +2 -2
  14. overcode/cli/_shared.py +1 -1
  15. overcode/cli/agent.py +118 -51
  16. overcode/cli/daemon.py +1 -1
  17. overcode/cli/doctor.py +65 -12
  18. overcode/cli/hooks.py +75 -2
  19. overcode/cli/parallelism.py +2 -2
  20. overcode/cli/split.py +101 -18
  21. overcode/config.py +74 -1
  22. overcode/daemon_claude_skill.md +40 -3
  23. overcode/daemon_logging.py +46 -0
  24. overcode/dependency_check.py +61 -9
  25. overcode/doctor.py +91 -40
  26. overcode/exceptions.py +15 -7
  27. overcode/history_reader.py +330 -43
  28. overcode/hook_handler.py +296 -2
  29. overcode/hook_status_detector.py +62 -22
  30. overcode/launcher.py +279 -230
  31. overcode/monitor_daemon.py +131 -150
  32. overcode/monitor_daemon_state.py +12 -5
  33. overcode/opencode_plugin/overcode-telemetry.js +481 -0
  34. overcode/pricing.py +32 -0
  35. overcode/process_resources.py +2 -2
  36. overcode/session_manager.py +167 -35
  37. overcode/settings.py +32 -0
  38. overcode/sister_poller.py +24 -0
  39. overcode/stats_reader.py +334 -0
  40. overcode/status_constants.py +1 -1
  41. overcode/status_detector.py +34 -44
  42. overcode/status_detector_factory.py +98 -20
  43. overcode/status_history.py +303 -0
  44. overcode/status_patterns.py +233 -54
  45. overcode/summary_columns.py +82 -14
  46. overcode/supervisor_daemon.py +1 -0
  47. overcode/supervisor_daemon_core.py +10 -1
  48. overcode/tui.py +92 -31
  49. overcode/tui_actions/input.py +2 -2
  50. overcode/tui_actions/session.py +70 -16
  51. overcode/tui_logic.py +5 -5
  52. overcode/tui_widgets/agent_select_modal.py +5 -5
  53. overcode/tui_widgets/daemon_panel.py +11 -11
  54. overcode/tui_widgets/help_overlay.py +1 -1
  55. overcode/tui_widgets/new_agent_defaults_modal.py +53 -9
  56. overcode/tui_widgets/new_agent_modal.py +30 -13
  57. overcode/tui_widgets/session_summary.py +15 -5
  58. overcode/tui_widgets/tui_log_panel.py +8 -8
  59. overcode/web_api.py +13 -7
  60. overcode/web_control_api.py +67 -28
  61. overcode/wrapper.py +95 -27
  62. {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/METADATA +16 -4
  63. {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/RECORD +67 -56
  64. {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/WHEEL +1 -1
  65. {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/entry_points.txt +0 -0
  66. {overcode-0.4.2.dist-info → overcode-0.5.2.dist-info}/licenses/LICENSE +0 -0
  67. {overcode-0.4.2.dist-info → overcode-0.5.2.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,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