Skip to content
Merged
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: 26 additions & 5 deletions docs/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container>` or `docker exec -it <container> 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 <container> # 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 <container>`, `vp stop <agent>`, 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <container>` 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:
Expand Down
3 changes: 2 additions & 1 deletion src/vibepod/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)

Expand Down
89 changes: 89 additions & 0 deletions src/vibepod/commands/attach.py
Original file line number Diff line number Diff line change
@@ -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."
Comment thread
nezhar marked this conversation as resolved.
),
),
] = 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 <container>`."
)
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
43 changes: 35 additions & 8 deletions src/vibepod/commands/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}")
60 changes: 57 additions & 3 deletions src/vibepod/core/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
nezhar marked this conversation as resolved.
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:
Expand Down Expand Up @@ -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)
Comment thread
nezhar marked this conversation as resolved.
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(
Expand Down
Loading
Loading