Skip to content

Commit d1f98ec

Browse files
committed
Add initial version of task mode
1 parent ac709ce commit d1f98ec

9 files changed

Lines changed: 1434 additions & 180 deletions

File tree

docs/agents/index.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,68 @@ The argument is resolved as an agent name/shortcut first; anything else is looke
260260
- **`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.
261261
- **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`.
262262

263+
## Task mode (headless)
264+
265+
Run an agent non-interactively as a background task. Claude calls this "headless mode"; codex and auggie expose equivalent flags. VibePod wraps them in a uniform `vp task` command.
266+
267+
```bash
268+
vp task run claude "Summarize the README"
269+
# ✓ Task started: 9f1e8a20b4c14e2f89d7f6a1c3e42b99
270+
# container: vibepod-claude-abcdef12
271+
# follow: vp task logs 9f1e8a20b4c1 --follow
272+
```
273+
274+
The command starts a detached container, writes a row to `~/.config/vibepod/tasks.db`, and returns a task id. The container is **not** auto-removed, so you can inspect logs and exit status after it finishes.
275+
276+
### Supported agents (v1)
277+
278+
| Agent | Invocation inside container |
279+
|-------|------------------------------|
280+
| `claude` | `claude -p "<prompt>"` |
281+
| `codex` | `codex exec "<prompt>"` |
282+
| `auggie` | `auggie --print "<prompt>"` |
283+
284+
Other agents error with a clear message; support can be added by setting `headless_prefix` on their `AgentSpec`.
285+
286+
### Managing tasks
287+
288+
```bash
289+
vp task list # recent tasks + container status
290+
vp task list --agent claude # filter
291+
vp task list --json # machine-readable
292+
293+
vp task logs <id> # dump captured stdout/stderr
294+
vp task logs <id> --follow # stream
295+
296+
vp task status <id> # state, exit code, timestamps
297+
vp task rm <id> # remove task + its (stopped) container
298+
vp task rm <id> -f # kill running container before removing
299+
```
300+
301+
Task ids can be abbreviated to any unique prefix (e.g., the first 12 chars shown by `vp task list`).
302+
303+
### Passing agent flags
304+
305+
Anything after `--` is forwarded to the agent's command after the prompt, matching the CLI's documented form (`claude -p "..." --allowedTools ...`):
306+
307+
```bash
308+
vp task run claude "review staged changes" -- --output-format json
309+
```
310+
311+
### Auto-approval for automation
312+
313+
Headless tasks typically need to run without permission prompts. Pass `--ikwid` to apply the agent's documented auto-approve flag (e.g., `--dangerously-skip-permissions` for Claude):
314+
315+
```bash
316+
vp task run claude "fix the failing test in auth.py" --ikwid
317+
```
318+
319+
### Caveats
320+
321+
- **No `--resume` in v1.** Task mode starts a fresh session every time. Use the agent's own resume flag via passthrough (`-- --resume <session_id>` for Claude) if you need continuity.
322+
- **LLM config model flag is skipped in task mode.** `llm.base_url` / `llm.api_key` / `llm.model` are still injected as env vars, but the `--model`-style CLI flag is not appended — different agents place it differently relative to subcommands. Pass an explicit model via `--`.
323+
- **Task containers are not auto-removed.** Run `vp task rm <id>` when you're done inspecting results.
324+
263325
## Reattaching a terminal
264326

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

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 attach, config, doctor, list_cmd, logs, proxy, run, stop, update
10+
from vibepod.commands import attach, config, doctor, list_cmd, logs, proxy, run, stop, task, update
1111
from vibepod.constants import AGENT_SHORTCUTS, SUPPORTED_AGENTS
1212

1313
app = typer.Typer(
@@ -30,6 +30,7 @@
3030
app.add_typer(config.app, name="config")
3131
app.add_typer(proxy.app, name="proxy")
3232
app.add_typer(doctor.app, name="doctor")
33+
app.add_typer(task.app, name="task")
3334

3435

3536
def _register_run_alias(command_name: str, agent_name: str) -> None:

src/vibepod/commands/run.py

Lines changed: 14 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@
22

33
from __future__ import annotations
44

5-
import json
65
import os
76
import sys
87
import time
9-
from datetime import datetime, timezone
108
from pathlib import Path
119
from typing import Annotated, Any
1210

@@ -26,186 +24,23 @@
2624
from vibepod.core.allowed_dirs import add_allowed_dir, is_dir_allowed, is_protected_dir
2725
from vibepod.core.config import get_config
2826
from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag
27+
from vibepod.core.launch import (
28+
CLAUDE_TOKEN_FILENAME,
29+
agent_extra_volumes as _agent_extra_volumes,
30+
agent_init_commands as _agent_init_commands,
31+
get_container_ip as _get_container_ip,
32+
host_user as _host_user,
33+
init_entrypoint as _init_entrypoint,
34+
parse_env_pairs as _parse_env_pairs,
35+
read_claude_stored_token as _read_claude_stored_token,
36+
terminal_env_defaults as _terminal_env_defaults,
37+
update_container_mapping as _update_container_mapping,
38+
write_claude_stored_token as _write_claude_stored_token,
39+
x11_volumes_and_env as _x11_volumes_and_env,
40+
)
2941
from vibepod.core.session_logger import SessionLogger
3042
from vibepod.utils.console import error, info, success, warning
3143

32-
CLAUDE_TOKEN_FILENAME = "oauth-token"
33-
34-
35-
def _claude_stored_token_path(config_dir: Path) -> Path:
36-
return config_dir / CLAUDE_TOKEN_FILENAME
37-
38-
39-
def _read_claude_stored_token(config_dir: Path) -> str | None:
40-
path = _claude_stored_token_path(config_dir)
41-
try:
42-
token = path.read_text(encoding="utf-8").strip()
43-
except FileNotFoundError:
44-
return None
45-
except OSError as exc:
46-
warning(f"Could not read stored claude token at {path}: {exc}")
47-
return None
48-
return token or None
49-
50-
51-
def _write_claude_stored_token(config_dir: Path, token: str) -> Path:
52-
path = _claude_stored_token_path(config_dir)
53-
path.parent.mkdir(parents=True, exist_ok=True)
54-
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
55-
try:
56-
# fchmod overrides umask; os.open mode alone is umask-filtered
57-
os.fchmod(fd, 0o600)
58-
except OSError:
59-
warning(f"Could not restrict permissions on {path}; token may be readable by other users")
60-
with os.fdopen(fd, "w", encoding="utf-8") as f:
61-
f.write(token.strip() + "\n")
62-
return path
63-
64-
65-
def _parse_env_pairs(values: list[str]) -> dict[str, str]:
66-
parsed: dict[str, str] = {}
67-
for entry in values:
68-
if "=" not in entry:
69-
raise typer.BadParameter(f"Invalid --env value '{entry}', expected KEY=VALUE")
70-
key, value = entry.split("=", 1)
71-
if not key:
72-
raise typer.BadParameter("Environment variable key cannot be empty")
73-
parsed[key] = value
74-
return parsed
75-
76-
77-
def _agent_init_commands(agent: str, agent_cfg: dict[str, Any]) -> list[str]:
78-
"""Read and validate per-agent init commands from config."""
79-
raw_init = agent_cfg.get("init", [])
80-
if raw_init is None:
81-
return []
82-
83-
if isinstance(raw_init, str):
84-
items = [raw_init]
85-
elif isinstance(raw_init, list):
86-
items = raw_init
87-
else:
88-
raise typer.BadParameter(
89-
f"Invalid agents.{agent}.init value, expected a string or list of strings."
90-
)
91-
92-
commands: list[str] = []
93-
for index, item in enumerate(items, start=1):
94-
if not isinstance(item, str):
95-
raise typer.BadParameter(
96-
f"Invalid agents.{agent}.init[{index}] value, expected a string."
97-
)
98-
command = item.strip()
99-
if not command:
100-
raise typer.BadParameter(
101-
f"Invalid agents.{agent}.init[{index}] value, cannot be empty."
102-
)
103-
commands.append(command)
104-
return commands
105-
106-
107-
def _init_entrypoint(init_commands: list[str]) -> list[str]:
108-
"""Build a shell entrypoint that runs init commands before the agent command."""
109-
script = "\n".join(
110-
[
111-
"set -e",
112-
*init_commands,
113-
'exec "$@"',
114-
]
115-
)
116-
return ["/bin/sh", "-lc", script, "--"]
117-
118-
119-
def _get_container_ip(container: Any, network: str) -> str | None:
120-
"""Extract the container's IP address on the given Docker network."""
121-
try:
122-
network_settings = container.attrs.get("NetworkSettings")
123-
if not isinstance(network_settings, dict):
124-
return None
125-
networks = network_settings.get("Networks")
126-
if not isinstance(networks, dict):
127-
return None
128-
network_data = networks.get(network)
129-
if not isinstance(network_data, dict):
130-
return None
131-
ip = network_data.get("IPAddress")
132-
return ip if isinstance(ip, str) and ip else None
133-
except AttributeError:
134-
return None
135-
136-
137-
def _update_container_mapping(
138-
mapping_path: Path,
139-
ip: str,
140-
container_id: str,
141-
container_name: str,
142-
agent: str,
143-
) -> bool:
144-
"""Merge a new IP→container entry into containers.json atomically."""
145-
mapping: dict[str, dict[str, str]] = {}
146-
try:
147-
if mapping_path.exists():
148-
try:
149-
mapping = json.loads(mapping_path.read_text())
150-
except (json.JSONDecodeError, OSError):
151-
pass
152-
153-
mapping[ip] = {
154-
"container_id": container_id,
155-
"container_name": container_name,
156-
"agent": agent,
157-
"started_at": datetime.now(timezone.utc).isoformat(),
158-
}
159-
160-
tmp_path = mapping_path.with_suffix(".tmp")
161-
tmp_path.write_text(json.dumps(mapping, indent=2))
162-
os.replace(tmp_path, mapping_path)
163-
except OSError:
164-
return False
165-
return True
166-
167-
168-
def _agent_extra_volumes(agent: str, config_dir: Path) -> list[tuple[str, str, str]]:
169-
"""Return agent-specific bind mounts as (host_path, container_path, mode)."""
170-
if agent == "auggie":
171-
host = str(config_dir / ".augment")
172-
return [
173-
(host, "/root/.augment", "rw"),
174-
(host, "/home/node/.augment", "rw"),
175-
]
176-
if agent == "copilot":
177-
host = str(config_dir / ".copilot")
178-
return [
179-
(host, "/root/.copilot", "rw"),
180-
(host, "/home/node/.copilot", "rw"),
181-
(host, "/home/coder/.copilot", "rw"),
182-
]
183-
return []
184-
185-
186-
def _x11_volumes_and_env(display: str) -> tuple[list[tuple[str, str, str]], dict[str, str]]:
187-
"""Return X11 socket volumes and DISPLAY env for paste-image support."""
188-
volumes: list[tuple[str, str, str]] = [("/tmp/.X11-unix", "/tmp/.X11-unix", "rw")]
189-
env: dict[str, str] = {"DISPLAY": display}
190-
return volumes, env
191-
192-
193-
def _host_user() -> str | None:
194-
"""Return current user id in uid:gid format when available."""
195-
getuid = getattr(os, "getuid", None)
196-
getgid = getattr(os, "getgid", None)
197-
if not callable(getuid) or not callable(getgid):
198-
return None
199-
return f"{getuid()}:{getgid()}"
200-
201-
202-
def _terminal_env_defaults() -> dict[str, str]:
203-
"""Return host terminal-related env vars for interactive container apps."""
204-
keys = ("TERM", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "LANG")
205-
values = {key: value for key in keys if (value := os.environ.get(key))}
206-
values.setdefault("TERM", "xterm-256color")
207-
return values
208-
20944

21045
def _compose_file_present(workspace: Path) -> bool:
21146
return (workspace / "docker-compose.yml").exists() or (workspace / "compose.yml").exists()

0 commit comments

Comments
 (0)