Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions lib/ccbd/services/project_namespace_runtime/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion lib/cli/kill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/cli/kill_runtime/zombies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions lib/provider_backends/agy/__init__.py
Original file line number Diff line number Diff line change
@@ -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']
110 changes: 110 additions & 0 deletions lib/provider_backends/agy/launcher.py
Original file line number Diff line number Diff line change
@@ -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']
34 changes: 34 additions & 0 deletions lib/provider_backends/agy/manifest.py
Original file line number Diff line number Diff line change
@@ -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']
65 changes: 65 additions & 0 deletions lib/provider_backends/agy/session.py
Original file line number Diff line number Diff line change
@@ -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',
]
1 change: 1 addition & 0 deletions lib/provider_core/pathing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'gemini': '.gemini-session',
'opencode': '.opencode-session',
'droid': '.droid-session',
'agy': '.agy-session',
}


Expand Down
4 changes: 3 additions & 1 deletion lib/provider_core/registry_runtime/builtin_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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(),
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions lib/provider_core/runtime_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'gemini': 'GEMINI_START_CMD',
'opencode': 'OPENCODE_START_CMD',
'droid': 'DROID_START_CMD',
'agy': 'AGY_START_CMD',
}

_PROVIDER_DEFAULT_EXECUTABLES = {
Expand All @@ -18,6 +19,7 @@
'gemini': 'gemini',
'opencode': 'opencode',
'droid': 'droid',
'agy': 'agy',
}


Expand Down
26 changes: 24 additions & 2 deletions test/test_v2_project_namespace_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading