diff --git a/lib/ccbd/services/project_namespace_runtime/backend.py b/lib/ccbd/services/project_namespace_runtime/backend.py index 6eed4e53a..4c308f310 100644 --- a/lib/ccbd/services/project_namespace_runtime/backend.py +++ b/lib/ccbd/services/project_namespace_runtime/backend.py @@ -42,12 +42,31 @@ def prepare_server(backend, *, timeout_s: float | None = None) -> None: def ensure_server_policy(backend, *, timeout_s: float | None = None) -> None: - _tmux_run_ready( - backend, - ['set-option', '-g', 'destroy-unattached', 'off'], - failure_message='failed to persist tmux destroy-unattached policy', - timeout_s=timeout_s, - ) + policies = [ + ( + ['set-option', '-g', 'destroy-unattached', 'off'], + 'failed to persist tmux destroy-unattached policy', + ), + ( + ['set-option', '-g', 'mouse', 'on'], + 'failed to enable tmux mouse support', + ), + ( + ['set-window-option', '-g', 'mode-keys', 'vi'], + 'failed to enable vi copy-mode keys', + ), + ( + ['set-option', '-g', 'history-limit', '50000'], + 'failed to set tmux history limit', + ), + ] + for args, failure_message in policies: + _tmux_run_ready( + backend, + args, + failure_message=failure_message, + timeout_s=timeout_s, + ) def create_session( diff --git a/lib/cli/kill.py b/lib/cli/kill.py index 115c20642..567b9abf6 100644 --- a/lib/cli/kill.py +++ b/lib/cli/kill.py @@ -47,7 +47,7 @@ def cmd_kill( yes = getattr(args, "yes", False) return kill_global_zombies(yes=yes, is_pid_alive=is_pid_alive) - providers = parse_providers(list(args.providers or ["codex", "gemini", "opencode", "claude", "droid"])) + providers = parse_providers(list(args.providers or ["codex", "gemini", "opencode", "claude", "droid", "agy"])) if not providers: return 2 diff --git a/lib/cli/kill_runtime/zombies.py b/lib/cli/kill_runtime/zombies.py index 12c1d1c53..87723e978 100644 --- a/lib/cli/kill_runtime/zombies.py +++ b/lib/cli/kill_runtime/zombies.py @@ -6,7 +6,7 @@ import subprocess from typing import Callable -_ZOMBIE_SESSION_PATTERN = re.compile(r"^(codex|gemini|opencode|claude|droid)-(\d+)-") +_ZOMBIE_SESSION_PATTERN = re.compile(r"^(codex|gemini|opencode|claude|droid|agy)-(\d+)-") def find_all_zombie_sessions( diff --git a/lib/provider_backends/agy/__init__.py b/lib/provider_backends/agy/__init__.py new file mode 100644 index 000000000..f7f0c741b --- /dev/null +++ b/lib/provider_backends/agy/__init__.py @@ -0,0 +1,17 @@ +from provider_core.contracts import ProviderBackend + +from .launcher import build_runtime_launcher +from .manifest import build_manifest +from .session import build_session_binding + + +def build_backend() -> ProviderBackend: + return ProviderBackend( + manifest=build_manifest(), + execution_adapter=None, + session_binding=build_session_binding(), + runtime_launcher=build_runtime_launcher(), + ) + + +__all__ = ['build_backend'] diff --git a/lib/provider_backends/agy/launcher.py b/lib/provider_backends/agy/launcher.py new file mode 100644 index 000000000..03c62d039 --- /dev/null +++ b/lib/provider_backends/agy/launcher.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import shlex +from pathlib import Path + +from agents.models import AgentSpec +from cli.context import CliContext +from cli.models import ParsedStartCommand +from provider_core.caller_env import ( + caller_context_env, + export_env_clause, + join_env_prefix, + provider_user_session_env, +) +from provider_core.contracts import ProviderRuntimeLauncher +from provider_core.runtime_shared import provider_start_parts +from workspace.models import WorkspacePlan + + +_YOLO_FLAG = '--dangerously-skip-permissions' + + +def build_runtime_launcher() -> ProviderRuntimeLauncher: + return ProviderRuntimeLauncher( + provider='agy', + launch_mode='simple_tmux', + prepare_launch_context=prepare_launch_context, + build_start_cmd=build_start_cmd, + build_session_payload=build_session_payload, + ) + + +def prepare_launch_context( + context: CliContext, + spec: AgentSpec, + plan: WorkspacePlan, + runtime_dir: Path, + prepared_state: dict[str, object], +) -> dict[str, object]: + del runtime_dir + payload = dict(prepared_state or {}) + payload['agent_name'] = spec.name + payload['project_root'] = str(context.project.project_root) + payload['workspace_path'] = str(prepared_state.get('run_cwd') or plan.workspace_path) + payload['agent_events_path'] = str(context.paths.agent_events_path(spec.name)) + return payload + + +def build_start_cmd( + command: ParsedStartCommand, + spec: AgentSpec, + runtime_dir, + launch_session_id: str, + *, + prepared_state: dict[str, object] | None = None, +) -> str: + del prepared_state + cmd_parts = provider_start_parts('agy') + if command.auto_permission and _YOLO_FLAG not in cmd_parts and _YOLO_FLAG not in spec.startup_args: + cmd_parts.append(_YOLO_FLAG) + if command.restore and not _has_restore_arg(cmd_parts) and not _has_restore_arg(spec.startup_args): + cmd_parts.append('--continue') + cmd_parts.extend(spec.startup_args) + cmd = ' '.join(shlex.quote(str(part)) for part in cmd_parts) + runtime_dir = Path(runtime_dir) + env_prefix = join_env_prefix( + export_env_clause(provider_user_session_env()), + export_env_clause(spec.env), + export_env_clause( + caller_context_env(actor=spec.name, runtime_dir=runtime_dir, launch_session_id=launch_session_id) + ), + ) + if env_prefix: + return f'{env_prefix}; {cmd}' + return cmd + + +def build_session_payload( + context: CliContext, + spec: AgentSpec, + plan: WorkspacePlan, + runtime_dir, + run_cwd, + pane_id: str, + pane_title_marker: str, + start_cmd: str, + launch_session_id: str, + prepared_state: dict[str, object], +) -> dict[str, object]: + del context, spec, prepared_state + return { + 'ccb_session_id': launch_session_id, + 'runtime_dir': str(runtime_dir), + 'completion_artifact_dir': str(runtime_dir / 'completion'), + 'terminal': 'tmux', + 'tmux_session': pane_id, + 'pane_id': pane_id, + 'pane_title_marker': pane_title_marker, + 'workspace_path': str(plan.workspace_path), + 'work_dir': str(run_cwd), + 'start_cmd': start_cmd, + } + + +def _has_restore_arg(parts: tuple[str, ...] | list[str]) -> bool: + normalized = {str(part).strip() for part in parts} + return bool({'--continue', '-c', '--conversation'} & normalized) + + +__all__ = ['build_runtime_launcher', 'build_start_cmd', 'prepare_launch_context'] diff --git a/lib/provider_backends/agy/manifest.py b/lib/provider_backends/agy/manifest.py new file mode 100644 index 000000000..fc2d8d9aa --- /dev/null +++ b/lib/provider_backends/agy/manifest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from agents.models import RuntimeMode +from completion.models import CompletionFamily, CompletionSourceKind, SelectorFamily +from completion.profiles import CompletionManifest +from provider_core.manifests import ProviderManifest + + +def build_manifest() -> ProviderManifest: + return ProviderManifest( + provider='agy', + supports_resume=True, + supports_permission_auto=True, + supports_stream_watch=False, + supports_subagents=True, + supports_workspace_attach=True, + runtime_profiles={ + RuntimeMode.PANE_BACKED: CompletionManifest( + provider='agy', + runtime_mode=RuntimeMode.PANE_BACKED.value, + completion_family=CompletionFamily.TERMINAL_TEXT_QUIET, + completion_source_kind=CompletionSourceKind.TERMINAL_TEXT, + supports_exact_completion=False, + supports_observed_completion=False, + supports_anchor_binding=False, + supports_reply_stability=False, + supports_terminal_reason=False, + selector_family=SelectorFamily.FINAL_MESSAGE, + ), + }, + ) + + +__all__ = ['build_manifest'] diff --git a/lib/provider_backends/agy/session.py b/lib/provider_backends/agy/session.py new file mode 100644 index 000000000..9ba119c34 --- /dev/null +++ b/lib/provider_backends/agy/session.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from provider_backends.pane_log_support.session import ( + PaneLogProjectSessionBase, + build_session_binding_for_provider, + compute_session_key_for_provider, + load_project_session_for_provider, +) +from provider_core.contracts import ProviderSessionBinding + + +@dataclass +class AgyProjectSession(PaneLogProjectSessionBase): + @property + def agy_session_id(self) -> str: + return str(self.data.get('agy_session_id') or self.data.get('ccb_session_id') or '').strip() + + @property + def agy_session_path(self) -> str: + return str(self.session_file) + + def backend(self): + from terminal_runtime import get_backend_for_session + + return get_backend_for_session(self.data) + + +def find_project_session_file(work_dir: Path, instance: Optional[str] = None) -> Optional[Path]: + from provider_backends.pane_log_support.session import find_project_session_file_for_provider + + return find_project_session_file_for_provider( + work_dir, + session_filename='.agy-session', + instance=instance, + ) + + +def load_project_session(work_dir: Path, instance: Optional[str] = None) -> Optional[AgyProjectSession]: + return load_project_session_for_provider( + work_dir, + session_filename='.agy-session', + session_cls=AgyProjectSession, + instance=instance, + ) + + +def compute_session_key(session: AgyProjectSession, instance: Optional[str] = None) -> str: + return compute_session_key_for_provider(session, provider='agy', instance=instance) + + +def build_session_binding() -> ProviderSessionBinding: + return build_session_binding_for_provider(provider='agy', load_session=load_project_session) + + +__all__ = [ + 'AgyProjectSession', + 'build_session_binding', + 'compute_session_key', + 'find_project_session_file', + 'load_project_session', +] diff --git a/lib/provider_core/pathing.py b/lib/provider_core/pathing.py index b45d1cadc..eeeb54722 100644 --- a/lib/provider_core/pathing.py +++ b/lib/provider_core/pathing.py @@ -11,6 +11,7 @@ 'gemini': '.gemini-session', 'opencode': '.opencode-session', 'droid': '.droid-session', + 'agy': '.agy-session', } diff --git a/lib/provider_core/registry_runtime/builtin_backends.py b/lib/provider_core/registry_runtime/builtin_backends.py index 6999c1fd0..454d51f19 100644 --- a/lib/provider_core/registry_runtime/builtin_backends.py +++ b/lib/provider_core/registry_runtime/builtin_backends.py @@ -3,7 +3,7 @@ from provider_core.contracts import ProviderBackend CORE_PROVIDER_NAMES = ("codex", "claude", "gemini") -OPTIONAL_PROVIDER_NAMES = ("opencode", "droid") +OPTIONAL_PROVIDER_NAMES = ("opencode", "droid", "agy") def build_builtin_backends(*, include_optional: bool = True) -> list[ProviderBackend]: @@ -12,6 +12,7 @@ def build_builtin_backends(*, include_optional: bool = True) -> list[ProviderBac from provider_backends.droid import build_backend as build_droid_backend from provider_backends.gemini import build_backend as build_gemini_backend from provider_backends.opencode import build_backend as build_opencode_backend + from provider_backends.agy import build_backend as build_agy_backend backends = [ build_codex_backend(), @@ -22,6 +23,7 @@ def build_builtin_backends(*, include_optional: bool = True) -> list[ProviderBac backends.extend([ build_opencode_backend(), build_droid_backend(), + build_agy_backend(), ]) return backends diff --git a/lib/provider_core/runtime_shared.py b/lib/provider_core/runtime_shared.py index cff25eefa..b44e7d9ec 100644 --- a/lib/provider_core/runtime_shared.py +++ b/lib/provider_core/runtime_shared.py @@ -10,6 +10,7 @@ 'gemini': 'GEMINI_START_CMD', 'opencode': 'OPENCODE_START_CMD', 'droid': 'DROID_START_CMD', + 'agy': 'AGY_START_CMD', } _PROVIDER_DEFAULT_EXECUTABLES = { @@ -18,6 +19,7 @@ 'gemini': 'gemini', 'opencode': 'opencode', 'droid': 'droid', + 'agy': 'agy', } diff --git a/test/test_v2_project_namespace_backend.py b/test/test_v2_project_namespace_backend.py index beb1b512a..d400194b2 100644 --- a/test/test_v2_project_namespace_backend.py +++ b/test/test_v2_project_namespace_backend.py @@ -127,6 +127,18 @@ def test_prepare_server_then_create_session_and_server_policy_retry_transient_tm ) == 2 +def test_ensure_server_policy_applies_interactive_tmux_defaults(monkeypatch) -> None: + monkeypatch.setenv('CCB_TMUX_OBJECT_READY_POLL_INTERVAL_S', '0') + backend = _FlakyBackend() + + ensure_server_policy(backend) + + assert ('set-option', '-g', 'destroy-unattached', 'off') in backend.calls + assert ('set-option', '-g', 'mouse', 'on') in backend.calls + assert ('set-window-option', '-g', 'mode-keys', 'vi') in backend.calls + assert ('set-option', '-g', 'history-limit', '50000') in backend.calls + + def test_prepare_server_accepts_fast_probe_timeout(monkeypatch) -> None: monkeypatch.setenv('CCB_TMUX_OBJECT_READY_POLL_INTERVAL_S', '0') backend = _FlakyBackend() @@ -147,7 +159,12 @@ def test_prepare_server_does_not_require_server_policy_before_session_exists(mon assert backend.calls[0] == ('start-server',) assert ('set-option', '-g', 'destroy-unattached', 'off') not in backend.calls[:2] - assert backend.calls[-1] == ('set-option', '-g', 'destroy-unattached', 'off') + assert backend.calls[-4:] == [ + ('set-option', '-g', 'destroy-unattached', 'off'), + ('set-option', '-g', 'mouse', 'on'), + ('set-window-option', '-g', 'mode-keys', 'vi'), + ('set-option', '-g', 'history-limit', '50000'), + ] def test_list_windows_retries_transient_tmux_failures(monkeypatch) -> None: @@ -304,7 +321,12 @@ def test_ensure_server_policy_accepts_fast_probe_timeout(monkeypatch) -> None: ensure_server_policy(backend, timeout_s=0.0) - assert backend.calls == [('set-option', '-g', 'destroy-unattached', 'off')] + assert backend.calls == [ + ('set-option', '-g', 'destroy-unattached', 'off'), + ('set-option', '-g', 'mouse', 'on'), + ('set-window-option', '-g', 'mode-keys', 'vi'), + ('set-option', '-g', 'history-limit', '50000'), + ] def test_kill_window_accepts_fast_probe_timeout(monkeypatch) -> None: diff --git a/test/test_v2_project_namespace_state.py b/test/test_v2_project_namespace_state.py index b9c48b8f4..5b816695e 100644 --- a/test/test_v2_project_namespace_state.py +++ b/test/test_v2_project_namespace_state.py @@ -60,6 +60,8 @@ class _FakeTmuxBackend: pane_options: dict[str, dict[str, str]] = field(default_factory=dict) session_options: dict[str, dict[str, str]] = field(default_factory=dict) window_options: dict[str, dict[str, str]] = field(default_factory=dict) + global_options: dict[str, str] = field(default_factory=dict) + global_window_options: dict[str, str] = field(default_factory=dict) hooks: dict[str, dict[str, str]] = field(default_factory=dict) tmux_calls: list[tuple[list[str], bool]] = field(default_factory=list) window_visibility_lag: dict[str, int] = field(default_factory=dict) @@ -145,7 +147,11 @@ def _tmux_run( self.tmux_calls.append((list(args), capture)) if args[:1] == ['start-server']: return SimpleNamespace(returncode=0, stdout='', stderr='') - if args[:3] == ['set-option', '-g', 'destroy-unattached']: + if len(args) >= 4 and args[:2] == ['set-option', '-g']: + self.global_options[args[2]] = args[3] + return SimpleNamespace(returncode=0, stdout='', stderr='') + if len(args) >= 4 and args[:2] == ['set-window-option', '-g']: + self.global_window_options[args[2]] = args[3] return SimpleNamespace(returncode=0, stdout='', stderr='') if len(args) >= 3 and args[:2] == ['has-session', '-t']: return SimpleNamespace(returncode=0 if args[2] in self.sessions else 1, stdout='', stderr='') @@ -656,4 +662,7 @@ def test_project_namespace_controller_uses_silent_server_commands(tmp_path: Path assert new_session_calls[0][-3:] == ['sh', '-lc', 'while :; do sleep 3600; done'] assert (['start-server'], True) in backend.tmux_calls assert (['set-option', '-g', 'destroy-unattached', 'off'], True) in backend.tmux_calls + assert (['set-option', '-g', 'mouse', 'on'], True) in backend.tmux_calls + assert (['set-window-option', '-g', 'mode-keys', 'vi'], True) in backend.tmux_calls + assert (['set-option', '-g', 'history-limit', '50000'], True) in backend.tmux_calls assert (['kill-server'], True) in backend.tmux_calls diff --git a/test/test_v2_provider_catalog.py b/test/test_v2_provider_catalog.py index a0ff20bcd..4a13c3f13 100644 --- a/test/test_v2_provider_catalog.py +++ b/test/test_v2_provider_catalog.py @@ -28,6 +28,7 @@ def test_default_provider_catalog_contains_expected_profiles() -> None: 'gemini', 'opencode', 'droid', + 'agy', } codex = catalog.resolve_completion_manifest('codex', RuntimeMode.PANE_BACKED) assert codex.completion_family is CompletionFamily.PROTOCOL_TURN @@ -39,6 +40,9 @@ def test_default_provider_catalog_contains_expected_profiles() -> None: assert fake_codex.completion_family is CompletionFamily.PROTOCOL_TURN fake_gemini = catalog.resolve_completion_manifest('fake-gemini', RuntimeMode.PANE_BACKED) assert fake_gemini.completion_family is CompletionFamily.ANCHORED_SESSION_STABILITY + assert catalog.get('agy').supports_resume is True + agy = catalog.resolve_completion_manifest('agy', RuntimeMode.PANE_BACKED) + assert agy.completion_family is CompletionFamily.TERMINAL_TEXT_QUIET fake_legacy = catalog.resolve_completion_manifest('fake-legacy', RuntimeMode.PANE_BACKED) assert fake_legacy.completion_family is CompletionFamily.TERMINAL_TEXT_QUIET @@ -81,4 +85,4 @@ def test_provider_catalog_can_build_core_only_catalog() -> None: catalog = build_default_provider_catalog(include_optional=False, include_test_doubles=False) assert set(catalog.providers()) == set(CORE_PROVIDER_NAMES) assert set(CORE_PROVIDER_NAMES) == {'codex', 'claude', 'gemini'} - assert set(OPTIONAL_PROVIDER_NAMES) == {'opencode', 'droid'} + assert set(OPTIONAL_PROVIDER_NAMES) == {'opencode', 'droid', 'agy'} diff --git a/test/test_v2_provider_core_registry.py b/test/test_v2_provider_core_registry.py index a251b3570..341430c96 100644 --- a/test/test_v2_provider_core_registry.py +++ b/test/test_v2_provider_core_registry.py @@ -31,17 +31,19 @@ def test_backend_registry_exposes_manifests_execution_and_session_bindings() -> def test_default_session_binding_map_uses_backend_owned_entries() -> None: bindings = build_default_session_binding_map(include_optional=True) - assert set(bindings) == {'codex', 'claude', 'gemini', 'opencode', 'droid'} + assert set(bindings) == {'codex', 'claude', 'gemini', 'opencode', 'droid', 'agy'} assert bindings['codex'].session_id_attr == 'codex_session_id' assert bindings['opencode'].session_path_attr == 'session_file' + assert bindings['agy'].session_path_attr == 'agy_session_path' def test_default_runtime_launcher_map_uses_backend_owned_entries() -> None: launchers = build_default_runtime_launcher_map(include_optional=True) - assert set(launchers) == {'codex', 'claude', 'gemini', 'opencode', 'droid'} + assert set(launchers) == {'codex', 'claude', 'gemini', 'opencode', 'droid', 'agy'} assert launchers['codex'].launch_mode == 'codex_tmux' assert launchers['gemini'].launch_mode == 'simple_tmux' + assert launchers['agy'].launch_mode == 'simple_tmux' def test_session_filename_for_agent_follows_agent_first_naming() -> None: diff --git a/test/test_v2_runtime_launch.py b/test/test_v2_runtime_launch.py index bfed7c278..63a7423d5 100644 --- a/test/test_v2_runtime_launch.py +++ b/test/test_v2_runtime_launch.py @@ -1379,10 +1379,12 @@ def test_provider_start_parts_respect_env_override(monkeypatch: pytest.MonkeyPat monkeypatch.setenv('GEMINI_START_CMD', '/tmp/stub-gemini --flag') monkeypatch.setenv('CLAUDE_START_CMD', '/tmp/stub-claude') monkeypatch.setenv('CODEX_START_CMD', '/tmp/stub-codex --profile test') + monkeypatch.setenv('AGY_START_CMD', '/tmp/stub-agy --sandbox') assert runtime_launch._provider_start_parts('gemini') == ['/tmp/stub-gemini', '--flag'] assert runtime_launch._provider_start_parts('claude') == ['/tmp/stub-claude'] assert runtime_launch._provider_start_parts('codex') == ['/tmp/stub-codex', '--profile', 'test'] + assert runtime_launch._provider_start_parts('agy') == ['/tmp/stub-agy', '--sandbox'] assert runtime_launch._provider_executable('codex') == '/tmp/stub-codex' @@ -1390,10 +1392,40 @@ def test_provider_start_parts_fall_back_to_default_binary(monkeypatch: pytest.Mo monkeypatch.delenv('GEMINI_START_CMD', raising=False) monkeypatch.delenv('CLAUDE_START_CMD', raising=False) monkeypatch.delenv('CODEX_START_CMD', raising=False) + monkeypatch.delenv('AGY_START_CMD', raising=False) assert runtime_launch._provider_start_parts('gemini') == ['gemini'] assert runtime_launch._provider_start_parts('claude') == ['claude'] assert runtime_launch._provider_start_parts('codex') == ['codex'] + assert runtime_launch._provider_start_parts('agy') == ['agy'] + + +def test_agy_start_cmd_defaults_to_yolo_permission(tmp_path: Path) -> None: + from provider_backends.agy import launcher as agy_launcher + + spec = _spec('debugger', provider='agy') + cmd = agy_launcher.build_start_cmd( + ParsedStartCommand(project=None, agent_names=('debugger',), restore=False, auto_permission=True), + spec, + tmp_path / 'runtime', + 'ccb-debugger-test', + ) + + assert 'agy --dangerously-skip-permissions' in cmd + + +def test_agy_start_cmd_uses_continue_for_restore(tmp_path: Path) -> None: + from provider_backends.agy import launcher as agy_launcher + + spec = _spec('debugger', provider='agy') + cmd = agy_launcher.build_start_cmd( + ParsedStartCommand(project=None, agent_names=('debugger',), restore=True, auto_permission=False), + spec, + tmp_path / 'runtime', + 'ccb-debugger-test', + ) + + assert 'agy --continue' in cmd def test_ensure_agent_runtime_falls_back_when_created_pane_is_too_small(monkeypatch, tmp_path: Path) -> None: