diff --git a/docs/agents/index.md b/docs/agents/index.md index 03e4880..8522d13 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -244,20 +244,41 @@ vp list --running # shows only running agents vp list --json # machine-readable output ``` -Stop a specific agent or all agents: +Stop a specific agent, a single container, or all agents: ```bash -vp stop claude # graceful stop (10 s timeout) -vp stop claude -f # force stop immediately -vp stop --all # stop every VibePod container +vp stop claude # stop every container for the `claude` agent +vp stop vibepod-claude-a1b2c3d4 # stop one specific container (from `vp list`) +vp stop claude -f # force stop immediately +vp stop --all # stop every VibePod container ``` +The argument is resolved as an agent name/shortcut first; anything else is looked up as a container name or ID. Only VibePod-managed containers can be stopped this way. + ### Caveats - **`auto_remove` (default: `true`)** — By default, containers are automatically removed when they stop. This means you cannot restart a stopped detached container; you need to `vp run` again. Set `auto_remove: false` in your [configuration](../configuration.md) if you want stopped containers to persist. -- **No built-in re-attach** — VibePod does not currently have a command to re-attach your terminal to a detached container. Use `docker attach ` or `docker exec -it bash` directly. - **Session logging** — Sessions started with `--detach` are not recorded in the VibePod session log since VibePod does not capture the interactive I/O. If you need session logging, run without `--detach`. +## Reattaching a terminal + +Closing the terminal window that runs `vp run` does **not** stop the container — the agent keeps running in the background under Docker. This is by design: the container's lifecycle is tied to Docker, not to your shell. Use it as a feature when you want to keep a long-running session alive across terminal restarts. + +To rejoin a running container: + +```bash +vp list --running # find the container name +vp attach # reattach your terminal +``` + +If exactly one managed container is running you can omit the name: + +```bash +vp attach +``` + +`vp attach` only works for containers that are already running and managed by VibePod. When you are done, close the terminal to leave it running, or stop it explicitly with `vp stop `, `vp stop `, or `vp stop --all`. + ## Connecting to a Docker Compose network When your workspace contains a `docker-compose.yml` or `compose.yml`, VibePod detects it and offers to connect the agent container to an existing network so it can reach your running services. diff --git a/docs/quickstart.md b/docs/quickstart.md index 222efed..75cf789 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -35,6 +35,9 @@ VibePod will: Press **Ctrl+C** to stop the container when you are done. +!!! note + Closing the terminal window does not stop the container — the agent keeps running in the background. Use `vp list --running` to see it and `vp attach ` to rejoin the session. See [Reattaching a terminal](agents/index.md#reattaching-a-terminal) for details. + ## Shortcuts You can start agents with either the full name or a single-letter shortcut: diff --git a/src/vibepod/cli.py b/src/vibepod/cli.py index 83ebd1e..b736a0f 100644 --- a/src/vibepod/cli.py +++ b/src/vibepod/cli.py @@ -7,7 +7,7 @@ import typer -from vibepod.commands import config, doctor, list_cmd, logs, proxy, run, stop, update +from vibepod.commands import attach, config, doctor, list_cmd, logs, proxy, run, stop, update from vibepod.constants import AGENT_SHORTCUTS, SUPPORTED_AGENTS app = typer.Typer( @@ -22,6 +22,7 @@ context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, )(run.run) app.command(name="stop")(stop.stop) +app.command(name="attach")(attach.attach) app.command(name="list")(list_cmd.list_agents) app.command(name="version")(update.version) diff --git a/src/vibepod/commands/attach.py b/src/vibepod/commands/attach.py new file mode 100644 index 0000000..df061dd --- /dev/null +++ b/src/vibepod/commands/attach.py @@ -0,0 +1,89 @@ +"""Attach command implementation.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from vibepod.constants import CONTAINER_LABEL_MANAGED, EXIT_DOCKER_NOT_RUNNING +from vibepod.core.docker import DockerClientError, DockerManager +from vibepod.utils.console import error, info, warning + + +def attach( + container: Annotated[ + str | None, + typer.Argument( + help=( + "Container name or ID to attach to (see `vp list`). " + "Omit when exactly one managed container is running." + ), + ), + ] = None, +) -> None: + """Reattach your terminal to a running VibePod-managed container. + + Use this to rejoin an agent session after the terminal that started it + was closed. Find candidate containers with `vp list`. + """ + try: + manager = DockerManager() + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(EXIT_DOCKER_NOT_RUNNING) from exc + + if container is None: + try: + managed = manager.list_managed() + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + running = [ + c + for c in managed + if getattr(c, "status", "") == "running" + and (getattr(c, "labels", {}) or {}).get("vibepod.agent") + ] + if not running: + error( + "No running VibePod agent containers to attach to. " + "Start one with `vp run`, or check `vp list --running`." + ) + raise typer.Exit(1) + if len(running) > 1: + names = ", ".join(sorted(c.name for c in running)) + error( + f"Multiple running containers: {names}. " + "Specify one explicitly: `vp attach `." + ) + raise typer.Exit(1) + target = running[0] + else: + try: + target = manager.get_container(container) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + labels = getattr(target, "labels", {}) or {} + if labels.get(CONTAINER_LABEL_MANAGED) != "true": + error(f"Container '{container}' is not managed by VibePod.") + raise typer.Exit(1) + if getattr(target, "status", "") != "running": + error( + f"Container '{container}' is not running " + f"(status: {getattr(target, 'status', 'unknown')})." + ) + raise typer.Exit(1) + + agent = (getattr(target, "labels", {}) or {}).get("vibepod.agent", "agent") + info(f"Attaching to {target.name} ({agent})") + warning( + f"Close the terminal to leave it running, or stop it with `vp stop {target.name}`." + ) + try: + manager.attach_interactive(target) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc diff --git a/src/vibepod/commands/stop.py b/src/vibepod/commands/stop.py index 8651f2b..a02910d 100644 --- a/src/vibepod/commands/stop.py +++ b/src/vibepod/commands/stop.py @@ -7,21 +7,30 @@ import typer from vibepod.constants import EXIT_DOCKER_NOT_RUNNING +from vibepod.core.agents import resolve_agent_name from vibepod.core.docker import DockerClientError, DockerManager from vibepod.utils.console import error, success def stop( - agent: Annotated[str | None, typer.Argument(help="Agent to stop")] = None, + target: Annotated[ + str | None, + typer.Argument( + help=( + "Agent name/shortcut (stops all its containers) or a container " + "name or ID from `vp list` (stops just that container)." + ), + ), + ] = None, all_containers: Annotated[ bool, typer.Option("-a", "--all", help="Stop all VibePod managed containers"), ] = False, force: Annotated[bool, typer.Option("-f", "--force", help="Force stop")] = False, ) -> None: - """Stop one agent container, or all managed containers.""" - if not all_containers and agent is None: - raise typer.BadParameter("Provide an AGENT or use --all") + """Stop an agent's containers, a specific container, or all managed containers.""" + if not all_containers and target is None: + raise typer.BadParameter("Provide an AGENT or CONTAINER, or use --all") try: manager = DockerManager() @@ -30,10 +39,28 @@ def stop( raise typer.Exit(EXIT_DOCKER_NOT_RUNNING) from exc if all_containers: - stopped = manager.stop_all(force=force) + try: + stopped = manager.stop_all(force=force) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc success(f"Stopped {stopped} container(s)") return - assert agent is not None - stopped = manager.stop_agent(agent=agent, force=force) - success(f"Stopped {stopped} container(s) for {agent}") + assert target is not None + resolved_agent = resolve_agent_name(target) + if resolved_agent is not None: + try: + stopped = manager.stop_agent(agent=resolved_agent, force=force) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + success(f"Stopped {stopped} container(s) for {resolved_agent}") + return + + try: + container = manager.stop_container(target, force=force) + except DockerClientError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + success(f"Stopped {container.name}") diff --git a/src/vibepod/core/docker.py b/src/vibepod/core/docker.py index 3463af3..62a5c1f 100644 --- a/src/vibepod/core/docker.py +++ b/src/vibepod/core/docker.py @@ -124,6 +124,16 @@ def connect_network(self, container: Any, network_name: str) -> None: except APIError as exc: raise DockerClientError(f"Failed to connect to network {network_name}: {exc}") from exc + def get_container(self, name_or_id: str) -> Any: + try: + return self.client.containers.get(name_or_id) + except NotFound as exc: + raise DockerClientError(f"Container '{name_or_id}' not found") from exc + except APIError as exc: + raise DockerClientError(f"Failed to look up container '{name_or_id}': {exc}") from exc + except DockerException as exc: + raise DockerClientError(f"Failed to look up container '{name_or_id}': {exc}") from exc + def resolve_launch_command(self, image: str, command: list[str] | None) -> list[str]: """Resolve the full executable argv for a container start.""" try: @@ -218,23 +228,67 @@ def run_agent( def stop_agent(self, agent: str, force: bool = False) -> int: stopped = 0 + timeout = 0 if force else 10 for container in self.list_managed(all_containers=True): if container.labels.get("vibepod.agent") != agent: continue - container.stop(timeout=0 if force else 10) + try: + container.stop(timeout=timeout) + except APIError as exc: + raise DockerClientError( + f"Failed to stop container '{container.name}': {exc}" + ) from exc + except DockerException as exc: + raise DockerClientError( + f"Failed to stop container '{container.name}': {exc}" + ) from exc stopped += 1 return stopped + def stop_container(self, name_or_id: str, force: bool = False) -> Any: + container = self.get_container(name_or_id) + labels = getattr(container, "labels", {}) or {} + if labels.get(CONTAINER_LABEL_MANAGED) != "true": + raise DockerClientError( + f"Container '{name_or_id}' is not managed by VibePod; refusing to stop." + ) + try: + container.stop(timeout=0 if force else 10) + except APIError as exc: + raise DockerClientError( + f"Failed to stop container '{name_or_id}': {exc}" + ) from exc + except DockerException as exc: + raise DockerClientError( + f"Failed to stop container '{name_or_id}': {exc}" + ) from exc + return container + def stop_all(self, force: bool = False) -> int: stopped = 0 + timeout = 0 if force else 10 for container in self.list_managed(all_containers=True): - container.stop(timeout=0 if force else 10) + try: + container.stop(timeout=timeout) + except APIError as exc: + raise DockerClientError( + f"Failed to stop container '{container.name}': {exc}" + ) from exc + except DockerException as exc: + raise DockerClientError( + f"Failed to stop container '{container.name}': {exc}" + ) from exc stopped += 1 return stopped def list_managed(self, all_containers: bool = False) -> list[Any]: filters = {"label": f"{CONTAINER_LABEL_MANAGED}=true"} - return list(self.client.containers.list(all=all_containers, filters=filters)) + try: + return list(self.client.containers.list(all=all_containers, filters=filters)) + except APIError as exc: + raise DockerClientError(f"Failed to list containers: {exc}") from exc + except DockerException as exc: + raise DockerClientError(f"Failed to list containers: {exc}") from exc def find_datasette(self) -> Any | None: containers = self.client.containers.list( diff --git a/tests/test_attach.py b/tests/test_attach.py new file mode 100644 index 0000000..052c6e6 --- /dev/null +++ b/tests/test_attach.py @@ -0,0 +1,172 @@ +"""Attach command tests.""" + +from __future__ import annotations + +import pytest +import typer + +from vibepod.commands import attach as attach_cmd +from vibepod.constants import CONTAINER_LABEL_MANAGED, EXIT_DOCKER_NOT_RUNNING +from vibepod.core.docker import DockerClientError + +_UNSET: dict[str, str] = {"__unset__": ""} + + +class _FakeContainer: + def __init__( + self, + name: str, + status: str = "running", + labels: dict[str, str] = _UNSET, + ) -> None: + self.name = name + self.status = status + if labels is _UNSET: + labels = {CONTAINER_LABEL_MANAGED: "true", "vibepod.agent": "claude"} + self.labels = labels + + +def _managed_container(name: str = "vibepod-claude-abc", status: str = "running") -> _FakeContainer: + return _FakeContainer( + name, + status=status, + labels={CONTAINER_LABEL_MANAGED: "true", "vibepod.agent": "claude"}, + ) + + +def test_attach_exits_when_docker_unavailable(monkeypatch) -> None: + def _raise() -> None: + raise DockerClientError("Docker is not available") + + class _UnavailableManager: + def __init__(self) -> None: + _raise() + + monkeypatch.setattr(attach_cmd, "DockerManager", _UnavailableManager) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container=None) + assert exc.value.exit_code == EXIT_DOCKER_NOT_RUNNING + + +def test_attach_no_arg_errors_when_no_running_containers(monkeypatch) -> None: + class _Manager: + def list_managed(self): + return [] + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container=None) + assert exc.value.exit_code == 1 + + +def test_attach_no_arg_errors_on_multiple_running(monkeypatch) -> None: + class _Manager: + def list_managed(self): + return [ + _managed_container("vibepod-claude-1"), + _managed_container("vibepod-claude-2"), + ] + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container=None) + assert exc.value.exit_code == 1 + + +def test_attach_no_arg_auto_picks_single_running(monkeypatch) -> None: + attached: list[_FakeContainer] = [] + only = _managed_container("vibepod-claude-solo") + + class _Manager: + def list_managed(self): + return [only] + + def attach_interactive(self, container, logger=None): # noqa: ARG002 + attached.append(container) + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + attach_cmd.attach(container=None) + + assert attached == [only] + + +def test_attach_ignores_non_running_when_auto_selecting(monkeypatch) -> None: + running = _managed_container("vibepod-claude-running", status="running") + exited = _managed_container("vibepod-claude-exited", status="exited") + attached: list[_FakeContainer] = [] + + class _Manager: + def list_managed(self): + return [exited, running] + + def attach_interactive(self, container, logger=None): # noqa: ARG002 + attached.append(container) + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + attach_cmd.attach(container=None) + + assert attached == [running] + + +def test_attach_by_name_succeeds(monkeypatch) -> None: + target = _managed_container("vibepod-claude-named") + attached: list[_FakeContainer] = [] + + class _Manager: + def get_container(self, name_or_id: str): + assert name_or_id == "vibepod-claude-named" + return target + + def attach_interactive(self, container, logger=None): # noqa: ARG002 + attached.append(container) + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + attach_cmd.attach(container="vibepod-claude-named") + + assert attached == [target] + + +def test_attach_by_name_rejects_unmanaged(monkeypatch) -> None: + unmanaged = _FakeContainer("random-container", labels={}) + + class _Manager: + def get_container(self, name_or_id: str): # noqa: ARG002 + return unmanaged + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container="random-container") + assert exc.value.exit_code == 1 + + +def test_attach_by_name_rejects_stopped(monkeypatch) -> None: + stopped = _managed_container("vibepod-claude-stopped", status="exited") + + class _Manager: + def get_container(self, name_or_id: str): # noqa: ARG002 + return stopped + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container="vibepod-claude-stopped") + assert exc.value.exit_code == 1 + + +def test_attach_by_name_not_found(monkeypatch) -> None: + class _Manager: + def get_container(self, name_or_id: str): + raise DockerClientError(f"Container '{name_or_id}' not found") + + monkeypatch.setattr(attach_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + attach_cmd.attach(container="does-not-exist") + assert exc.value.exit_code == 1 diff --git a/tests/test_stop.py b/tests/test_stop.py new file mode 100644 index 0000000..dcb4a08 --- /dev/null +++ b/tests/test_stop.py @@ -0,0 +1,169 @@ +"""Stop command tests.""" + +from __future__ import annotations + +import pytest +import typer + +from vibepod.commands import stop as stop_cmd +from vibepod.constants import CONTAINER_LABEL_MANAGED, EXIT_DOCKER_NOT_RUNNING +from vibepod.core.docker import DockerClientError + +_UNSET: dict[str, str] = {"__unset__": ""} + + +class _FakeContainer: + def __init__( + self, + name: str, + labels: dict[str, str] = _UNSET, + ) -> None: + self.name = name + if labels is _UNSET: + labels = {CONTAINER_LABEL_MANAGED: "true", "vibepod.agent": "claude"} + self.labels = labels + self.stop_timeout: int | None = None + + def stop(self, timeout: int = 10) -> None: + self.stop_timeout = timeout + + +def test_stop_requires_target_or_all(monkeypatch) -> None: + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: pytest.fail("should not be called")) + with pytest.raises(typer.BadParameter): + stop_cmd.stop(target=None, all_containers=False, force=False) + + +def test_stop_docker_unavailable_exits_with_status(monkeypatch) -> None: + def _unavailable() -> None: + raise DockerClientError("Docker is not available") + + class _Manager: + def __init__(self) -> None: + _unavailable() + + monkeypatch.setattr(stop_cmd, "DockerManager", _Manager) + + with pytest.raises(typer.Exit) as exc: + stop_cmd.stop(target="claude", all_containers=False, force=False) + assert exc.value.exit_code == EXIT_DOCKER_NOT_RUNNING + + +def test_stop_by_agent_name_dispatches_to_stop_agent(monkeypatch) -> None: + calls: dict = {} + + class _Manager: + def stop_agent(self, agent: str, force: bool = False) -> int: + calls["agent"] = agent + calls["force"] = force + return 2 + + def stop_container(self, name_or_id: str, force: bool = False): # pragma: no cover + pytest.fail("stop_container should not be called for a known agent") + + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: _Manager()) + + stop_cmd.stop(target="claude", all_containers=False, force=True) + + assert calls == {"agent": "claude", "force": True} + + +def test_stop_by_agent_shortcut_is_resolved(monkeypatch) -> None: + calls: dict = {} + + class _Manager: + def stop_agent(self, agent: str, force: bool = False) -> int: + calls["agent"] = agent + calls["force"] = force + return 1 + + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: _Manager()) + + stop_cmd.stop(target="c", all_containers=False, force=False) + + assert calls == {"agent": "claude", "force": False} + + +def test_stop_by_container_name_dispatches_to_stop_container(monkeypatch) -> None: + calls: dict = {} + container = _FakeContainer("vibepod-claude-abc12345") + + class _Manager: + def stop_agent(self, agent: str, force: bool = False) -> int: # pragma: no cover + pytest.fail("stop_agent should not be called for a container name") + + def stop_container(self, name_or_id: str, force: bool = False): + calls["name_or_id"] = name_or_id + calls["force"] = force + return container + + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: _Manager()) + + stop_cmd.stop(target="vibepod-claude-abc12345", all_containers=False, force=False) + + assert calls == {"name_or_id": "vibepod-claude-abc12345", "force": False} + + +def test_stop_by_container_id_propagates_errors(monkeypatch) -> None: + class _Manager: + def stop_container(self, name_or_id: str, force: bool = False): + raise DockerClientError(f"Container '{name_or_id}' not found") + + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: _Manager()) + + with pytest.raises(typer.Exit) as exc: + stop_cmd.stop(target="bogus-id", all_containers=False, force=False) + assert exc.value.exit_code == 1 + + +def test_stop_all_flag_uses_stop_all(monkeypatch) -> None: + calls: dict = {} + + class _Manager: + def stop_all(self, force: bool = False) -> int: + calls["force"] = force + return 3 + + monkeypatch.setattr(stop_cmd, "DockerManager", lambda: _Manager()) + + stop_cmd.stop(target=None, all_containers=True, force=True) + + assert calls == {"force": True} + + +def test_manager_stop_container_rejects_unmanaged() -> None: + unmanaged = _FakeContainer("random", labels={}) + + class _FakeClient: + class containers: # noqa: N801 + @staticmethod + def get(name_or_id: str): # noqa: ARG004 + return unmanaged + + from vibepod.core.docker import DockerManager + + manager = object.__new__(DockerManager) + manager.client = _FakeClient() # type: ignore[assignment] + + with pytest.raises(DockerClientError): + manager.stop_container("random") + assert unmanaged.stop_timeout is None + + +def test_manager_stop_container_stops_managed() -> None: + managed = _FakeContainer("vibepod-claude-xyz") + + class _FakeClient: + class containers: # noqa: N801 + @staticmethod + def get(name_or_id: str): # noqa: ARG004 + return managed + + from vibepod.core.docker import DockerManager + + manager = object.__new__(DockerManager) + manager.client = _FakeClient() # type: ignore[assignment] + + result = manager.stop_container("vibepod-claude-xyz", force=True) + assert result is managed + assert managed.stop_timeout == 0