Skip to content

Commit ac709ce

Browse files
committed
Add vp attach and extend vp stop to accept container names
1 parent 9e907a1 commit ac709ce

8 files changed

Lines changed: 553 additions & 17 deletions

File tree

docs/agents/index.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,20 +244,41 @@ vp list --running # shows only running agents
244244
vp list --json # machine-readable output
245245
```
246246

247-
Stop a specific agent or all agents:
247+
Stop a specific agent, a single container, or all agents:
248248

249249
```bash
250-
vp stop claude # graceful stop (10 s timeout)
251-
vp stop claude -f # force stop immediately
252-
vp stop --all # stop every VibePod container
250+
vp stop claude # stop every container for the `claude` agent
251+
vp stop vibepod-claude-a1b2c3d4 # stop one specific container (from `vp list`)
252+
vp stop claude -f # force stop immediately
253+
vp stop --all # stop every VibePod container
253254
```
254255

256+
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.
257+
255258
### Caveats
256259

257260
- **`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.
258-
- **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.
259261
- **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`.
260262

263+
## Reattaching a terminal
264+
265+
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.
266+
267+
To rejoin a running container:
268+
269+
```bash
270+
vp list --running # find the container name
271+
vp attach <container> # reattach your terminal
272+
```
273+
274+
If exactly one managed container is running you can omit the name:
275+
276+
```bash
277+
vp attach
278+
```
279+
280+
`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`.
281+
261282
## Connecting to a Docker Compose network
262283

263284
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.

docs/quickstart.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ VibePod will:
3535

3636
Press **Ctrl+C** to stop the container when you are done.
3737

38+
!!! note
39+
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.
40+
3841
## Shortcuts
3942

4043
You can start agents with either the full name or a single-letter shortcut:

src/vibepod/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99

10-
from vibepod.commands import config, doctor, list_cmd, logs, proxy, run, stop, update
10+
from vibepod.commands import attach, config, doctor, list_cmd, logs, proxy, run, stop, update
1111
from vibepod.constants import AGENT_SHORTCUTS, SUPPORTED_AGENTS
1212

1313
app = typer.Typer(
@@ -22,6 +22,7 @@
2222
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
2323
)(run.run)
2424
app.command(name="stop")(stop.stop)
25+
app.command(name="attach")(attach.attach)
2526
app.command(name="list")(list_cmd.list_agents)
2627
app.command(name="version")(update.version)
2728

src/vibepod/commands/attach.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Attach command implementation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated
6+
7+
import typer
8+
9+
from vibepod.constants import CONTAINER_LABEL_MANAGED, EXIT_DOCKER_NOT_RUNNING
10+
from vibepod.core.docker import DockerClientError, DockerManager
11+
from vibepod.utils.console import error, info, warning
12+
13+
14+
def attach(
15+
container: Annotated[
16+
str | None,
17+
typer.Argument(
18+
help=(
19+
"Container name or ID to attach to (see `vp list`). "
20+
"Omit when exactly one managed container is running."
21+
),
22+
),
23+
] = None,
24+
) -> None:
25+
"""Reattach your terminal to a running VibePod-managed container.
26+
27+
Use this to rejoin an agent session after the terminal that started it
28+
was closed. Find candidate containers with `vp list`.
29+
"""
30+
try:
31+
manager = DockerManager()
32+
except DockerClientError as exc:
33+
error(str(exc))
34+
raise typer.Exit(EXIT_DOCKER_NOT_RUNNING) from exc
35+
36+
if container is None:
37+
try:
38+
managed = manager.list_managed()
39+
except DockerClientError as exc:
40+
error(str(exc))
41+
raise typer.Exit(1) from exc
42+
running = [
43+
c
44+
for c in managed
45+
if getattr(c, "status", "") == "running"
46+
and (getattr(c, "labels", {}) or {}).get("vibepod.agent")
47+
]
48+
if not running:
49+
error(
50+
"No running VibePod agent containers to attach to. "
51+
"Start one with `vp run`, or check `vp list --running`."
52+
)
53+
raise typer.Exit(1)
54+
if len(running) > 1:
55+
names = ", ".join(sorted(c.name for c in running))
56+
error(
57+
f"Multiple running containers: {names}. "
58+
"Specify one explicitly: `vp attach <container>`."
59+
)
60+
raise typer.Exit(1)
61+
target = running[0]
62+
else:
63+
try:
64+
target = manager.get_container(container)
65+
except DockerClientError as exc:
66+
error(str(exc))
67+
raise typer.Exit(1) from exc
68+
69+
labels = getattr(target, "labels", {}) or {}
70+
if labels.get(CONTAINER_LABEL_MANAGED) != "true":
71+
error(f"Container '{container}' is not managed by VibePod.")
72+
raise typer.Exit(1)
73+
if getattr(target, "status", "") != "running":
74+
error(
75+
f"Container '{container}' is not running "
76+
f"(status: {getattr(target, 'status', 'unknown')})."
77+
)
78+
raise typer.Exit(1)
79+
80+
agent = (getattr(target, "labels", {}) or {}).get("vibepod.agent", "agent")
81+
info(f"Attaching to {target.name} ({agent})")
82+
warning(
83+
f"Close the terminal to leave it running, or stop it with `vp stop {target.name}`."
84+
)
85+
try:
86+
manager.attach_interactive(target)
87+
except DockerClientError as exc:
88+
error(str(exc))
89+
raise typer.Exit(1) from exc

src/vibepod/commands/stop.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,30 @@
77
import typer
88

99
from vibepod.constants import EXIT_DOCKER_NOT_RUNNING
10+
from vibepod.core.agents import resolve_agent_name
1011
from vibepod.core.docker import DockerClientError, DockerManager
1112
from vibepod.utils.console import error, success
1213

1314

1415
def stop(
15-
agent: Annotated[str | None, typer.Argument(help="Agent to stop")] = None,
16+
target: Annotated[
17+
str | None,
18+
typer.Argument(
19+
help=(
20+
"Agent name/shortcut (stops all its containers) or a container "
21+
"name or ID from `vp list` (stops just that container)."
22+
),
23+
),
24+
] = None,
1625
all_containers: Annotated[
1726
bool,
1827
typer.Option("-a", "--all", help="Stop all VibePod managed containers"),
1928
] = False,
2029
force: Annotated[bool, typer.Option("-f", "--force", help="Force stop")] = False,
2130
) -> None:
22-
"""Stop one agent container, or all managed containers."""
23-
if not all_containers and agent is None:
24-
raise typer.BadParameter("Provide an AGENT or use --all")
31+
"""Stop an agent's containers, a specific container, or all managed containers."""
32+
if not all_containers and target is None:
33+
raise typer.BadParameter("Provide an AGENT or CONTAINER, or use --all")
2534

2635
try:
2736
manager = DockerManager()
@@ -30,10 +39,28 @@ def stop(
3039
raise typer.Exit(EXIT_DOCKER_NOT_RUNNING) from exc
3140

3241
if all_containers:
33-
stopped = manager.stop_all(force=force)
42+
try:
43+
stopped = manager.stop_all(force=force)
44+
except DockerClientError as exc:
45+
error(str(exc))
46+
raise typer.Exit(1) from exc
3447
success(f"Stopped {stopped} container(s)")
3548
return
3649

37-
assert agent is not None
38-
stopped = manager.stop_agent(agent=agent, force=force)
39-
success(f"Stopped {stopped} container(s) for {agent}")
50+
assert target is not None
51+
resolved_agent = resolve_agent_name(target)
52+
if resolved_agent is not None:
53+
try:
54+
stopped = manager.stop_agent(agent=resolved_agent, force=force)
55+
except DockerClientError as exc:
56+
error(str(exc))
57+
raise typer.Exit(1) from exc
58+
success(f"Stopped {stopped} container(s) for {resolved_agent}")
59+
return
60+
61+
try:
62+
container = manager.stop_container(target, force=force)
63+
except DockerClientError as exc:
64+
error(str(exc))
65+
raise typer.Exit(1) from exc
66+
success(f"Stopped {container.name}")

src/vibepod/core/docker.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ def connect_network(self, container: Any, network_name: str) -> None:
124124
except APIError as exc:
125125
raise DockerClientError(f"Failed to connect to network {network_name}: {exc}") from exc
126126

127+
def get_container(self, name_or_id: str) -> Any:
128+
try:
129+
return self.client.containers.get(name_or_id)
130+
except NotFound as exc:
131+
raise DockerClientError(f"Container '{name_or_id}' not found") from exc
132+
except APIError as exc:
133+
raise DockerClientError(f"Failed to look up container '{name_or_id}': {exc}") from exc
134+
except DockerException as exc:
135+
raise DockerClientError(f"Failed to look up container '{name_or_id}': {exc}") from exc
136+
127137
def resolve_launch_command(self, image: str, command: list[str] | None) -> list[str]:
128138
"""Resolve the full executable argv for a container start."""
129139
try:
@@ -218,23 +228,67 @@ def run_agent(
218228

219229
def stop_agent(self, agent: str, force: bool = False) -> int:
220230
stopped = 0
231+
timeout = 0 if force else 10
221232
for container in self.list_managed(all_containers=True):
222233
if container.labels.get("vibepod.agent") != agent:
223234
continue
224-
container.stop(timeout=0 if force else 10)
235+
try:
236+
container.stop(timeout=timeout)
237+
except APIError as exc:
238+
raise DockerClientError(
239+
f"Failed to stop container '{container.name}': {exc}"
240+
) from exc
241+
except DockerException as exc:
242+
raise DockerClientError(
243+
f"Failed to stop container '{container.name}': {exc}"
244+
) from exc
225245
stopped += 1
226246
return stopped
227247

248+
def stop_container(self, name_or_id: str, force: bool = False) -> Any:
249+
container = self.get_container(name_or_id)
250+
labels = getattr(container, "labels", {}) or {}
251+
if labels.get(CONTAINER_LABEL_MANAGED) != "true":
252+
raise DockerClientError(
253+
f"Container '{name_or_id}' is not managed by VibePod; refusing to stop."
254+
)
255+
try:
256+
container.stop(timeout=0 if force else 10)
257+
except APIError as exc:
258+
raise DockerClientError(
259+
f"Failed to stop container '{name_or_id}': {exc}"
260+
) from exc
261+
except DockerException as exc:
262+
raise DockerClientError(
263+
f"Failed to stop container '{name_or_id}': {exc}"
264+
) from exc
265+
return container
266+
228267
def stop_all(self, force: bool = False) -> int:
229268
stopped = 0
269+
timeout = 0 if force else 10
230270
for container in self.list_managed(all_containers=True):
231-
container.stop(timeout=0 if force else 10)
271+
try:
272+
container.stop(timeout=timeout)
273+
except APIError as exc:
274+
raise DockerClientError(
275+
f"Failed to stop container '{container.name}': {exc}"
276+
) from exc
277+
except DockerException as exc:
278+
raise DockerClientError(
279+
f"Failed to stop container '{container.name}': {exc}"
280+
) from exc
232281
stopped += 1
233282
return stopped
234283

235284
def list_managed(self, all_containers: bool = False) -> list[Any]:
236285
filters = {"label": f"{CONTAINER_LABEL_MANAGED}=true"}
237-
return list(self.client.containers.list(all=all_containers, filters=filters))
286+
try:
287+
return list(self.client.containers.list(all=all_containers, filters=filters))
288+
except APIError as exc:
289+
raise DockerClientError(f"Failed to list containers: {exc}") from exc
290+
except DockerException as exc:
291+
raise DockerClientError(f"Failed to list containers: {exc}") from exc
238292

239293
def find_datasette(self) -> Any | None:
240294
containers = self.client.containers.list(

0 commit comments

Comments
 (0)