diff --git a/AGENTS.md b/AGENTS.md index ab7779a..49ea3d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,17 @@ vibepod-agents image entrypoint), the per-agent defaults in The local-image fallback (`VP_IMAGE_` override + pull-failure fallback in `run.py`/`task.py`) is how an unreleased image is exercised locally. +## State-reporting integrations (herdr, dash) + +`core/herdr.py` and `core/dash.py` share the same shape: a config gate, a +data-driven map of vendored files to inject into the agent config dir, and +per-agent registration (claude `settings.json`, codex `.codex/hooks.json`). +Both copy through `core/hooksync.py`, and both must soft-fail โ€” a broken +integration never blocks a run. The dash client scripts are vendored from the +vibepod-dash repo; see `src/vibepod/resources/dash/README.md` before editing +them. Codex registration is shared through `core/codex_hooks.py`, which lets +Dash and herdr coexist and removes only VibePod's legacy `notify` entries. + ## Tests Runner is `pytest` (`python -m pytest`); CI also validates default images with diff --git a/README.md b/README.md index bb91f2e..f0de5fa 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ tracking, and an analytics dashboard to monitor and compare agents side-by-side. - ๐Ÿงฑ **Project overlays** โ€” commit a `FROM`-less Dockerfile fragment in `.vibepod/overlay/` and VibePod auto-builds a cached, content-addressed image layer on top of the agent's base image โ€” one clearly named image per project and agent ([docs](https://vibepod.dev/docs/overlays/)) - ๐Ÿ“Š **Local analytics dashboard** โ€” track usage and HTTP traffic per agent, plus token metrics - ๐Ÿ‘ **Herdr aware** โ€” `vp run` inside a [herdr](https://herdr.dev/) pane reports agent state automatically +- ๐Ÿ“ฑ **Web dashboard** โ€” point `VPDASH_URL` at a [VibePod Dash](https://github.com/VibePod/vibepod-dash) board and watch every running agent's state from your phone ([docs](https://vibepod.dev/docs/dash/)) - โš–๏ธ **Agent comparison** โ€” benchmark multiple agents against each other in the dashboard - ๐Ÿ”’ **Privacy-first** โ€” all metrics collected and stored locally, never sent to the cloud - ๐Ÿ“ฆ **Simple install** โ€” via pip, Homebrew, or conda-forge diff --git a/docs/configuration.md b/docs/configuration.md index ee95bdf..d9e216c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -172,6 +172,15 @@ logging: db_path: ~/.config/vibepod/logs.db ui_port: 8001 # Port for the Datasette UI +# Report agent state to a VibePod Dash board (see the Dash integration page) +dash: + enabled: true + url: "" # e.g. http://localhost:8765; empty disables reporting + token: "" # ingest token of the dash server + container_url: "" # Override the URL agents use; defaults to `url` with + # localhost rewritten to host.docker.internal + integrations: {} # Extra hook files per agent, {agent: [{source, dest}]} + proxy: enabled: true image: vibepod/proxy:latest @@ -202,6 +211,9 @@ These variables override the corresponding config keys without editing any file: | `VP_LLM_BASE_URL` | `llm.base_url` | `VP_LLM_BASE_URL=http://localhost:11434` | | `VP_LLM_API_KEY` | `llm.api_key` | `VP_LLM_API_KEY=ollama` | | `VP_LLM_MODEL` | `llm.model` | `VP_LLM_MODEL=qwen3:14b` | +| `VPDASH_URL` | `dash.url` | `VPDASH_URL=http://localhost:8765` | +| `VPDASH_TOKEN` | `dash.token` | `VPDASH_TOKEN=s3cret` | +| `VPDASH_CONTAINER_URL` | `dash.container_url` | `VPDASH_CONTAINER_URL=http://vibepod-dash:8765` | | `VP_CONFIG_DIR` | *(config root)* | `VP_CONFIG_DIR=/custom/path` | | `VP_PROFILE` | `profile` | `VP_PROFILE=work` | diff --git a/docs/dash.md b/docs/dash.md new file mode 100644 index 0000000..d35ed5a --- /dev/null +++ b/docs/dash.md @@ -0,0 +1,188 @@ +# Dash integration + +[VibePod Dash](https://github.com/VibePod/vibepod-dash) is a small web +dashboard for the state of your running agents โ€” the same idea as the +[herdr](herdr.md) sidebar, but in a browser, so you can check on your agents +from your phone. + +Point VibePod at a dash server and every `vp run` and `vp task create` shows up +on the board with a live state (working / blocked / idle / done / error): + +```bash +export VPDASH_URL=http://localhost:8765 +export VPDASH_TOKEN= +vp run claude +``` + +Or, permanently, in `~/.config/vibepod/config.yaml` (or the project's +`.vibepod/config.yaml`): + +```yaml +dash: + url: http://localhost:8765 + token: s3cret +``` + +## What VibePod wires up + +- `VPDASH_*` environment is injected into the container, including a stable + agent id and the display name `vp: ยท ` +- for agents with lifecycle hooks, a VibePod-managed reporter plus hook script + is copied into the agent's config dir and registered +- the CLI itself reports the container's start and stop, so **every** agent + appears on the board even without hooks +- `vp stop`, `vp task cancel` and a finished task mark the card done + +Built-in hook reporting ships for **claude**, **codex** and **copilot**. The +hooks need only `curl` inside the image. + +| Claude hook event | reported state | +| ------------------ | ----------------------- | +| `SessionStart` | `idle` | +| `UserPromptSubmit` | `working` (your prompt) | +| `PreToolUse` | `working` (tool name) | +| `PostToolUse` | `working` (tool name) | +| `Notification` | `blocked` (the message) | +| `Stop` | `idle` | +| `SessionEnd` | `done` | + +`blocked` is the state worth a phone notification: the agent is waiting for +your approval. + +Codex is registered through `.codex/hooks.json`, so Dash and herdr can both +receive the same lifecycle events. Its mapping is: + +| Codex hook event | reported state | +| ------------------ | ----------------------- | +| `SessionStart` | `idle` | +| `UserPromptSubmit` | `working` (your prompt) | +| `PreToolUse` | `working` (tool name) | +| `PostToolUse` | `working` (tool name) | +| `PermissionRequest` | `blocked` (the request) | +| `Stop` | `idle` | +| `Interrupt` | `idle` | +| `SessionEnd` | `done` | + +Codex may ask you to trust newly registered hooks once. Review them with +`/hooks` in Codex and approve the VibePod-managed commands. + +## What lands on the card + +Beyond the state, VibePod attaches the context only it knows. Tap a card on +the dashboard and it shows: + +| Detail | Where it comes from | +| ----------- | ------------------------------------------------------- | +| `dir` | the workspace directory mounted into the container | +| `image` | the resolved agent image, overlay included | +| `profile` | the active [credential profile](profiles.md) | +| `container` | the container name โ€” the handle for `vp attach` / `vp stop` | +| `task` | the task id, for `vp task logs` / `vp task cancel` | +| `vibepod` | the CLI version that started the run | + +The agent's own hooks add the live part: the prompt you sent, the tool being +run, the notification text it is blocked on. Details are sent once at start +and stay on the card โ€” a hook report that carries none never clears them. + +## Reaching the dashboard from inside the container + +`localhost` inside a container is the container itself, so a `localhost` or +`127.0.0.1` dash URL is rewritten to `host.docker.internal` for the agent โ€” +VibePod maps that name to the host gateway on every run. The CLI keeps using +the original URL for its own reports. A dash server on another machine (or in +another container) is passed through unchanged. + +That rewrite is enough on its own when the dash server's port is published on +the host, and it needs no configuration. + +The dash server's own `docker-compose.yml` goes one better: it joins +`vibepod-network` โ€” the network VibePod creates for its containers โ€” under the +alias `vibepod-dash`, so agents can address it by name instead of going back +out through the host, which also survives a rootless Podman setup where the +host gateway is the flakiest part of the chain. + +**Either URL works as the only thing you configure.** The CLI reports from the +host, so it cannot use a container-only name; when `dash.url` is one, it falls +back to the same port on `127.0.0.1` โ€” but only after checking that a +dashboard actually answers there. Both of these are complete configurations: + +```yaml +dash: + url: http://vibepod-dash:8765 # agents use it as-is; the CLI falls back to + # 127.0.0.1:8765, the published port +``` + +```yaml +dash: + url: http://localhost:8765 # the CLI uses it as-is; agents get + # host.docker.internal:8765 +``` + +Spell both out when neither default fits โ€” a dashboard on another host, or a +published port that differs from the container's: + +```yaml +dash: + url: http://dash.lan:9000 # how the CLI reports + container_url: http://vibepod-dash:8765 # how agents report +``` + +If you run agents on a custom network (`network:` in the config), put the dash +container on that one โ€” its compose file reads `VIBEPOD_NETWORK`. + +## Identity on the board + +One card per agent *and* workspace: the id is derived from host, agent and +workspace path, so re-running an agent in the same checkout updates the card it +had before instead of stacking up a new one. Override it per run with +`VPDASH_AGENT_ID` (one card per run) or rename the card with +`VPDASH_AGENT_NAME`. + +## Opting out + +- `vp run --no-dash` / `vp task create ... --no-dash` โ€” skip one run +- `dash: false` (or `dash: {enabled: false}`) in the config โ€” disable entirely +- no URL configured โ€” nothing is wired up at all, which is the default + +## Custom agents + +Like herdr, file injection is data-driven. To wire an agent without built-in +support, map host files into the agent's config directory: + +```yaml +dash: + url: http://localhost:8765 + integrations: + gemini: + - source: ~/.config/my-hooks/gemini-dash.sh + dest: hooks/gemini-dash.sh +``` + +Inside the container the script finds `VPDASH_URL`, `VPDASH_TOKEN`, +`VPDASH_AGENT`, `VPDASH_AGENT_ID`, `VPDASH_AGENT_NAME`, `VPDASH_HOST` and +`VPDASH_LOG` in the environment. Reporting is one HTTP call: + +```sh +curl -sS -X POST "$VPDASH_URL/api/v1/events" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $VPDASH_TOKEN" \ + -d '{"agent_id":"'"$VPDASH_AGENT_ID"'","state":"working","message":"โ€ฆ"}' +``` + +## Troubleshooting + +`vp doctor dash` prints the resolved configuration, checks that the board +answers, and summarises every agent's integration state. `vp doctor dash +` goes deeper: injected files, registration, the hook trace log +(`/dash-hook.log`), a live host-side report, and a probe that +runs the real hook inside the agent image, on the same network a real run uses +โ€” which is what proves the container can reach the dashboard, by name or +through the host gateway. + +## Limitations + +- Reports are HTTP calls from inside the container; an agent image without + `curl` can only be tracked by the CLI-side start/stop reports. +- The dashboard sees whatever the agent reports โ€” prompts, tool names, + notification text. Treat it as sensitive as the sessions it watches, and put + it behind a token (and TLS) when it is reachable beyond your LAN. diff --git a/docs/herdr.md b/docs/herdr.md index 5b37813..0e390c4 100644 --- a/docs/herdr.md +++ b/docs/herdr.md @@ -25,6 +25,12 @@ no documented hook or extension API for live state transitions. No setup is needed. Detection uses `HERDR_ENV=1`, which herdr sets only inside its panes. +Codex uses `.codex/hooks.json`, allowing the herdr and Dash integrations to +coexist. It reports `working` for prompts and tool activity, `blocked` for +permission requests, and `idle` for session start, stop, interruption, and +session end. Codex may show a one-time trust prompt for the registered hooks; +use `/hooks` to review and approve the VibePod-managed command. + ## Opting out - `vp run --no-herdr` โ€” skip wiring for one run diff --git a/docs/superpowers/plans/2026-09-03-codex-lifecycle-hooks.md b/docs/superpowers/plans/2026-09-03-codex-lifecycle-hooks.md new file mode 100644 index 0000000..efcf6d9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-codex-lifecycle-hooks.md @@ -0,0 +1,582 @@ +# Codex Lifecycle Hooks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace VibePod's legacy Codex `notify` wiring with coexistable lifecycle hooks for accurate dash and herdr state reporting, without changing agent images. + +**Architecture:** Add one shared JSON-preserving hook registrar in `vibepod.core.codex_hooks`, then have dash and herdr register independent commands for the same Codex lifecycle events. Each POSIX adapter consumes hook JSON on stdin and translates it to its existing bounded reporter; doctor commands inspect the shared hook configuration. + +**Tech Stack:** Python 3.10+, JSON/TOML configuration, POSIX shell, pytest, Ruff, mypy. + +--- + +## File structure + +- Create `src/vibepod/core/codex_hooks.py`: shared lifecycle event list, idempotent `hooks.json` merge, legacy VibePod `notify` cleanup, and diagnostic lookup. +- Create `tests/test_codex_hooks.py`: focused tests for merge, coexistence, preservation, migration, and malformed input. +- Modify `src/vibepod/core/herdr.py`: delegate Codex registration to the shared helper. +- Modify `src/vibepod/resources/herdr/codex/herdr-agent-state.sh`: consume lifecycle JSON from stdin and map all supported events. +- Modify `tests/test_herdr.py`: replace legacy notify assertions and exercise Codex state/session reporting. +- Create `src/vibepod/resources/codex/dash-agent-state.sh`: VibePod-owned lifecycle adapter that calls the unchanged vendored dash reporter. +- Modify `src/vibepod/core/dash.py`: copy the new adapter and delegate registration to the shared helper. +- Modify `tests/test_dash.py`: assert lifecycle registration, coexistence, and HTTP state mapping. +- Modify `src/vibepod/commands/doctor.py`: detect and probe Codex lifecycle hooks through stdin. +- Modify `docs/dash.md` and `docs/herdr.md`: document mappings and Codex hook trust. + +### Task 1: Shared Codex lifecycle-hook registrar + +**Files:** +- Create: `src/vibepod/core/codex_hooks.py` +- Create: `tests/test_codex_hooks.py` + +- [ ] **Step 1: Write failing creation and merge tests** + +Create tests that express the public helper API and exact JSON shape: + +```python +from __future__ import annotations + +import json +from pathlib import Path + +from vibepod.core import codex_hooks + + +def commands(path: Path, event: str) -> list[str]: + data = json.loads(path.read_text(encoding="utf-8")) + return [ + hook["command"] + for group in data["hooks"][event] + for hook in group.get("hooks", []) + if hook.get("type") == "command" + ] + + +def test_register_creates_every_lifecycle_event(tmp_path: Path) -> None: + codex_hooks.register(tmp_path, "/config/.codex/dash-agent-state.sh", label="dash") + path = tmp_path / ".codex" / "hooks.json" + for event in codex_hooks.LIFECYCLE_EVENTS: + assert commands(path, event) == ["/config/.codex/dash-agent-state.sh"] + + +def test_register_preserves_user_hooks_and_is_idempotent(tmp_path: Path) -> None: + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "description": "mine", + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": "mine.sh"}]}], + "PreCompact": [{"hooks": [{"type": "command", "command": "compact.sh"}]}], + }, + } + ), + encoding="utf-8", + ) + command = "/config/.codex/herdr-agent-state.sh" + codex_hooks.register(tmp_path, command, label="herdr") + first = path.read_text(encoding="utf-8") + codex_hooks.register(tmp_path, command, label="herdr") + assert path.read_text(encoding="utf-8") == first + data = json.loads(first) + assert data["description"] == "mine" + assert commands(path, "Stop") == ["mine.sh", command] + assert commands(path, "PreCompact") == ["compact.sh"] + + +def test_dash_and_herdr_handlers_coexist(tmp_path: Path) -> None: + dash = "/config/.codex/dash-agent-state.sh" + herdr = "/config/.codex/herdr-agent-state.sh" + codex_hooks.register(tmp_path, herdr, label="herdr") + codex_hooks.register(tmp_path, dash, label="dash") + path = tmp_path / ".codex" / "hooks.json" + for event in codex_hooks.LIFECYCLE_EVENTS: + assert commands(path, event) == [herdr, dash] +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +VP_CONFIG_DIR="$(mktemp -d /tmp/vp-config.XXXXXX)" python -m pytest tests/test_codex_hooks.py -v +``` + +Expected: collection fails because `vibepod.core.codex_hooks` does not exist. + +- [ ] **Step 3: Implement the minimal JSON merge** + +Create the module with this interface and behavior: + +```python +"""Shared Codex lifecycle-hook registration for VibePod integrations.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from vibepod.utils.console import warning + +LIFECYCLE_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Stop", + "Interrupt", + "SessionEnd", +) + +LEGACY_NOTIFY_LINES = frozenset( + { + 'notify = ["/config/.codex/herdr-agent-state.sh"]', + 'notify = ["/config/.codex/dash-agent-state.sh"]', + } +) + + +def _entry(command: str) -> dict[str, Any]: + return {"hooks": [{"type": "command", "command": command}]} + + +def register(config_dir: Path, command: str, *, label: str) -> bool: + """Merge one VibePod command into all Codex lifecycle events.""" + path = config_dir / ".codex" / "hooks.json" + data: dict[str, Any] = {} + if path.is_file(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + warning(f"{label}: could not parse codex hooks.json, skipping hook registration") + return False + if not isinstance(loaded, dict): + warning(f"{label}: codex hooks.json is not an object, skipping hook registration") + return False + data = loaded + hooks = data.setdefault("hooks", {}) + if not isinstance(hooks, dict): + warning(f"{label}: codex hooks.json 'hooks' is not an object, skipping") + return False + changed = False + for event in LIFECYCLE_EVENTS: + groups = hooks.setdefault(event, []) + if not isinstance(groups, list): + warning(f"{label}: codex hooks.json hooks['{event}'] is not a list, skipping") + continue + present = any( + hook.get("type") == "command" and hook.get("command") == command + for group in groups + if isinstance(group, dict) + for hook in group.get("hooks", []) + if isinstance(hook, dict) + ) + if not present: + groups.append(_entry(command)) + changed = True + if changed: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + _remove_legacy_notify(config_dir, label=label) + return True +``` + +Implement `registered(config_dir, command)` by safely loading `hooks.json` and +checking every configured group for the exact command. It returns `False` on +missing or malformed files and never warns, because doctor owns presentation. + +- [ ] **Step 4: Add failing migration and malformed-input tests** + +Append tests that require exact cleanup and soft failure: + +```python +import pytest + + +@pytest.mark.parametrize("legacy", sorted(codex_hooks.LEGACY_NOTIFY_LINES)) +def test_register_removes_legacy_vibepod_notify(tmp_path: Path, legacy: str) -> None: + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text(f'model = "gpt"\n{legacy}\n', encoding="utf-8") + codex_hooks.register(tmp_path, "/config/.codex/dash-agent-state.sh", label="dash") + assert legacy not in path.read_text(encoding="utf-8") + assert 'model = "gpt"' in path.read_text(encoding="utf-8") + + +def test_register_preserves_user_notify(tmp_path: Path) -> None: + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text('notify = ["my-notifier"]\n', encoding="utf-8") + codex_hooks.register(tmp_path, "/config/.codex/dash-agent-state.sh", label="dash") + assert path.read_text(encoding="utf-8") == 'notify = ["my-notifier"]\n' + + +def test_register_leaves_malformed_hooks_unchanged(tmp_path: Path, capsys) -> None: + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + assert not codex_hooks.register(tmp_path, "hook.sh", label="dash") + assert path.read_text(encoding="utf-8") == "{broken" + assert "hooks.json" in capsys.readouterr().out +``` + +- [ ] **Step 5: Verify the migration tests fail for the expected missing helper** + +Run the focused file again. Expected: merge tests pass; migration tests fail +because `_remove_legacy_notify` is not defined or does not remove managed lines. + +- [ ] **Step 6: Implement exact legacy cleanup** + +Add `_remove_legacy_notify`. Read `config.toml`, validate it with `tomllib`, +then remove only lines whose stripped form belongs to `LEGACY_NOTIFY_LINES`. +Preserve a trailing newline. On malformed TOML, warn and leave the file +unchanged. Do not create `config.toml` when it does not exist. + +- [ ] **Step 7: Run focused tests and commit** + +Run `tests/test_codex_hooks.py`; expect all tests to pass. Then commit: + +```bash +git add src/vibepod/core/codex_hooks.py tests/test_codex_hooks.py +git commit -m "feat: register shared Codex lifecycle hooks" +``` + +### Task 2: Herdr lifecycle adapter + +**Files:** +- Modify: `src/vibepod/core/herdr.py` +- Modify: `src/vibepod/resources/herdr/codex/herdr-agent-state.sh` +- Modify: `tests/test_herdr.py` + +- [ ] **Step 1: Replace legacy registration tests with lifecycle assertions** + +Update tests to call `herdr.register_codex_hooks(tmp_path)`, assert every event +contains `/config/.codex/herdr-agent-state.sh`, assert repeated registration is +byte-idempotent, and assert `apply_herdr_if_enabled("codex", ...)` creates +`.codex/hooks.json`. + +- [ ] **Step 2: Add a failing executable adapter test** + +Use the existing unix-socket test server and invoke the injected Codex script +with lifecycle JSON on stdin: + +```python +@pytest.mark.parametrize( + ("event", "state"), + [ + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("Interrupt", "idle"), + ("SessionEnd", "idle"), + ], +) +def test_codex_lifecycle_hook_reports_state( + event: str, + state: str, + monkeypatch, + sock_dir: Path, + tmp_path: Path, +) -> None: + received: list[dict] = [] + thread = _serve_one(sock_dir / "herdr.sock", received) + config_dir = tmp_path / "cfg" + config_dir.mkdir() + herdr.sync_herdr_files("codex", config_dir, {}) + subprocess.run( + ["sh", str(config_dir / ".codex" / "herdr-agent-state.sh")], + input=json.dumps({"hook_event_name": event, "session_id": "s1"}), + capture_output=True, + text=True, + check=True, + env={ + "PATH": os.environ["PATH"], + "HERDR_SOCKET_PATH": str(sock_dir / "herdr.sock"), + "HERDR_PANE_ID": "w1:p1", + "HOME": str(config_dir), + }, + ) + thread.join(timeout=5) + assert received[0]["params"]["state"] == state +``` + +Add a separate `SessionStart` test with a two-request server and assert the +first method is `pane.report_agent_session` with `agent_session_id` and +`agent_session_path`, followed by an idle `pane.report_agent` request. + +- [ ] **Step 3: Run the selected Herdr tests and verify RED** + +Expected failures: old `notify` API still exists and the adapter reads argv +instead of stdin, so lifecycle cases do not reach the socket. + +- [ ] **Step 4: Delegate registration and implement the adapter mapping** + +In `herdr.py`, remove its TOML-specific Codex implementation and expose: + +```python +from vibepod.core.codex_hooks import register as register_codex_lifecycle_hooks + +CODEX_HOOK_COMMAND = "/config/.codex/herdr-agent-state.sh" + + +def register_codex_hooks(config_dir: Path) -> None: + register_codex_lifecycle_hooks(config_dir, CODEX_HOOK_COMMAND, label="herdr") +``` + +Call this wrapper from `apply_herdr_if_enabled`. + +Rewrite the Codex adapter to read `payload=$(cat ...)`, extract +`hook_event_name`, `session_id`, and `transcript_path`, reuse the existing +socket/binary `send` shape from the Claude adapter, and implement: + +```sh +case "$event" in + SessionStart) + send pane.report_agent_session "" "$session_id" "$transcript" + send pane.report_agent idle "$session_id" "" + ;; + UserPromptSubmit|PreToolUse|PostToolUse) + send pane.report_agent working "$session_id" "" + ;; + PermissionRequest) + send pane.report_agent blocked "$session_id" "" + ;; + Stop|Interrupt|SessionEnd) + send pane.report_agent idle "$session_id" "" + ;; + *) log "ignore event=${event:-?}" ;; +esac +``` + +Capture reporter output for logging and emit nothing on stdout. + +- [ ] **Step 5: Run Herdr tests and commit** + +Run `tests/test_codex_hooks.py tests/test_herdr.py`; expect all to pass. Commit: + +```bash +git add src/vibepod/core/herdr.py src/vibepod/resources/herdr/codex/herdr-agent-state.sh tests/test_herdr.py +git commit -m "fix: report Herdr state from Codex lifecycle hooks" +``` + +### Task 3: Dash lifecycle adapter + +**Files:** +- Create: `src/vibepod/resources/codex/dash-agent-state.sh` +- Modify: `src/vibepod/core/dash.py` +- Modify: `tests/test_dash.py` + +- [ ] **Step 1: Write failing registration and resource tests** + +Replace notify tests with assertions that `dash.register_codex_hooks` adds the +dash command alongside a pre-existing herdr command. Update resource tests so +Codex injects the unchanged vendored `vpdash-report.sh` and the new +VibePod-owned adapter next to it. + +- [ ] **Step 2: Add failing HTTP mapping tests** + +Generalize the existing executable-hook test and parameterize Codex events: + +```python +@pytest.mark.parametrize( + ("event", "state"), + [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("Interrupt", "idle"), + ("SessionEnd", "done"), + ], +) +def test_codex_lifecycle_hook_reports_over_http( + event: str, + state: str, + dash_server: Any, + tmp_path: Path, +) -> None: + target = dash.make_target( + "codex", + Path("/work/proj"), + { + "dash": {"url": server_url(dash_server), "token": "t0ken"}, + }, + ) + assert target is not None + dash.sync_dash_files("codex", tmp_path, {}) + subprocess.run( + [str(tmp_path / ".codex" / "dash-agent-state.sh")], + input=json.dumps( + { + "hook_event_name": event, + "session_id": "s1", + "cwd": "/work/proj", + "prompt": "fix it", + "tool_name": "Bash", + } + ), + text=True, + check=True, + env={ + **os.environ, + **dash.container_env(target, str(tmp_path)), + "VPDASH_URL": server_url(dash_server), + }, + ) + assert dash_server.received[0][0]["state"] == state + assert dash_server.received[0][0]["event"] == event +``` + +- [ ] **Step 3: Run selected Dash tests and verify RED** + +Expected failures: lifecycle registration API/resource does not exist and the +legacy vendored adapter does not parse hook stdin. + +- [ ] **Step 4: Wire the local adapter without modifying vendored clients** + +Change dash's built-in resource root to the package `resources` directory and +prefix existing vendored paths with `dash/`. For Codex use: + +```python +"codex": [ + ("dash/vpdash-report.sh", ".codex/vpdash-report.sh"), + ("codex/dash-agent-state.sh", ".codex/dash-agent-state.sh"), +], +``` + +Expose `CODEX_HOOK_COMMAND` and `register_codex_hooks` through the shared +registrar, then call it from `apply_dash_if_enabled`. + +Create the POSIX adapter. It reads stdin, extracts common fields, verifies the +vendored reporter, and maps: + +```sh +case "$event" in + SessionStart) state=idle; message="session started" ;; + UserPromptSubmit) state=working; message=$(field prompt) ;; + PreToolUse|PostToolUse) state=working; message=$(field tool_name) ;; + PermissionRequest) + state=blocked + message=$(field description) + [ -n "$message" ] || message="$(field tool_name) needs approval" + ;; + Stop) state=idle; message=$(field last_assistant_message) ;; + Interrupt) state=idle; message="turn interrupted" ;; + SessionEnd) state=done; message="session ended" ;; + *) log "ignore event=${event:-?}"; exit 0 ;; +esac +``` + +Call `vpdash-report.sh` with the state, exact event, message, session id, and +cwd. Capture all reporter output into the trace log so the hook produces no +stdout control payload. + +- [ ] **Step 5: Run Dash tests and commit** + +Run `tests/test_codex_hooks.py tests/test_dash.py`; expect all to pass. Commit: + +```bash +git add src/vibepod/core/dash.py src/vibepod/resources/codex/dash-agent-state.sh tests/test_dash.py +git commit -m "fix: report Dash state from Codex lifecycle hooks" +``` + +### Task 4: Doctor and documentation + +**Files:** +- Modify: `src/vibepod/commands/doctor.py` +- Modify: `tests/test_dash.py` +- Modify: `tests/test_herdr.py` +- Modify: `docs/dash.md` +- Modify: `docs/herdr.md` + +- [ ] **Step 1: Write failing doctor tests** + +Add focused tests that create `.codex/hooks.json` through each integration, +invoke the relevant registration helper used by doctor, and assert the summary +or deep-dive reports `hooks.json`, not `notify`. Add a missing-registration case +that produces `MISSING` without raising on malformed JSON. + +- [ ] **Step 2: Run doctor tests and verify RED** + +Run the selected doctor tests. Expected: output still says `notify`, and both +in-container probes pass Codex payloads as argv. + +- [ ] **Step 3: Update diagnostics and probes** + +Import `vibepod.core.codex_hooks` in `doctor.py`. For Codex registration use: + +```python +ok = codex_hooks.registered(cfg_dir, dash_core.CODEX_HOOK_COMMAND) +registration = "hooks.json" if ok else "MISSING" +``` + +Use the corresponding herdr command for herdr diagnostics. Change both Codex +probe payloads to lifecycle JSON such as +`{"hook_event_name":"Stop","session_id":"doctor"}` and pipe stdin for Codex +the same way as the other hook-based agents. + +- [ ] **Step 4: Update user documentation** + +In both integration docs, state that Codex uses lifecycle hooks, document the +state mapping, and explain that Codex may require one-time review through +`/hooks`. Remove the dash limitation about the single notify program and the +herdr statement that Codex lacks working/blocked events. + +- [ ] **Step 5: Run focused tests and commit** + +Run: + +```bash +VP_CONFIG_DIR="$(mktemp -d /tmp/vp-config.XXXXXX)" python -m pytest \ + tests/test_codex_hooks.py tests/test_dash.py tests/test_herdr.py -v +``` + +Commit: + +```bash +git add src/vibepod/commands/doctor.py tests/test_dash.py tests/test_herdr.py docs/dash.md docs/herdr.md +git commit -m "docs: describe Codex lifecycle state reporting" +``` + +### Task 5: Full verification + +**Files:** +- Modify if necessary: files already listed above + +- [ ] **Step 1: Run the full hermetic unit suite** + +```bash +VP_CONFIG_DIR="$(mktemp -d /tmp/vp-config.XXXXXX)" python -m pytest +``` + +Expected: all non-integration tests pass. + +- [ ] **Step 2: Run static checks** + +```bash +python -m ruff check . +python -m ruff format --check . +python -m mypy src +git diff --check +``` + +Expected: every command exits zero with no new warnings. + +- [ ] **Step 3: Inspect the final diff against the design** + +Confirm no image constants, Dockerfiles, or vendored files under +`src/vibepod/resources/dash/` changed. Confirm only exact VibePod legacy notify +lines are removed, both commands coexist in `hooks.json`, and every report +path remains soft-failing. + +- [ ] **Step 4: Request code review and address findings** + +Review the complete diff from the design commit through `HEAD`, fix every +critical or important finding test-first, and rerun the checks above. diff --git a/docs/superpowers/specs/2026-09-03-codex-lifecycle-hooks-design.md b/docs/superpowers/specs/2026-09-03-codex-lifecycle-hooks-design.md new file mode 100644 index 0000000..64ca974 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-codex-lifecycle-hooks-design.md @@ -0,0 +1,133 @@ +# Codex Lifecycle Hooks Design + +## Objective + +Replace the legacy Codex `notify` integration used by VibePod's dash and +herdr integrations with Codex lifecycle hooks. Both integrations must report +accurate state transitions, coexist in the same Codex configuration, preserve +user configuration, and continue to soft-fail. This change does not modify or +rebuild any agent image. + +## Background + +Codex `notify` currently emits only `agent-turn-complete`. Consequently, the +existing dash and herdr adapters can report only `idle`; they cannot observe a +turn starting, tool activity, an approval request, interruption, or session +shutdown. Codex also permits only one `notify` command, so the integration +configured first prevents the other one from registering. + +Current Codex releases expose these events through `hooks.json`. Multiple +handlers can be registered for an event, which allows dash and herdr to remain +independent while receiving the same lifecycle event. + +## Configuration architecture + +A shared `vibepod.core.codex_hooks` helper will merge one integration's command +into `/.codex/hooks.json`. The helper accepts a command path and +the events that should invoke it. For every event it appends a command handler +only when that exact command is not already present. + +The merge must preserve: + +- existing top-level fields, including `description`; +- existing hook events and matcher groups; +- user-provided handlers in events also used by VibePod; +- handlers installed independently by dash and herdr; and +- a user-defined top-level `notify` setting in `config.toml`. + +If `hooks.json` is missing, the helper creates it with a top-level `hooks` +object. If the file is malformed, its top level is not an object, or its +`hooks` value is not an object, VibePod warns and leaves it unchanged. A broken +hook configuration must never block an agent run. + +VibePod will remove a legacy `notify` assignment only when its parsed value +exactly matches one of VibePod's former dash or herdr commands. This migration +applies whether one or both integrations are currently enabled, so a stale +VibePod assignment cannot continue claiming the user's single notification +slot. Other `notify` values are untouched. + +## Event adapters + +Codex lifecycle hooks pass one JSON object on standard input. The dash and +herdr Codex adapters will consume that input and inspect `hook_event_name`. +They remain POSIX shell scripts and emit no standard output, because several +Codex hook events treat command output as control data. + +Events map to state as follows: + +| Codex event | State | Message or metadata | +| --- | --- | --- | +| `SessionStart` | `idle` | Session started; herdr records session identity | +| `UserPromptSubmit` | `working` | Prompt where supported | +| `PreToolUse` | `working` | Tool name | +| `PostToolUse` | `working` | Tool name | +| `PermissionRequest` | `blocked` | Tool name or approval description | +| `Stop` | `idle` | Latest assistant message or waiting state | +| `Interrupt` | `idle` | Turn interrupted | +| `SessionEnd` | `done` for dash; `idle` for herdr | Session ended | + +Herdr has no terminal `done` state, so `SessionEnd` remains `idle` until the +existing container cleanup releases the herdr agent entry. Unknown events are +logged and ignored. + +Handlers run synchronously to preserve event ordering. Each adapter already +contains bounded network/socket operations and catches failures, so dashboard +or herdr outages remain non-fatal. + +## Integration behavior + +Dash and herdr each register their own command handler for every supported +event. Registration order is irrelevant: both commands remain in +`hooks.json`, and disabling one integration does not disable or rewrite the +other one's handlers. + +The existing injected reporter implementations and container environment +contract remain unchanged. Only the Codex lifecycle adapter and registration +mechanism change. The dash HTTP reporter remains the verbatim vendored client. +To preserve the repository rule that `resources/dash` stays synchronized with +the upstream dash repository, the lifecycle adapter used by VibePod will live +under `resources/codex` and call the vendored HTTP reporter. The old vendored +Codex `notify` adapter remains untouched but is no longer injected. + +Codex requires non-managed hooks to be reviewed and trusted. Documentation and +doctor output will identify `hooks.json` registration so users can use Codex's +`/hooks` interface when a new definition awaits trust. + +## Diagnostics and documentation + +`vp doctor herdr` and `vp doctor dash` will detect the appropriate command in +`.codex/hooks.json` rather than looking for it in `config.toml`. Diagnostic +checks must distinguish a missing file, malformed JSON, and an absent handler +without raising. + +The dash and herdr documentation will describe lifecycle-hook support and the +one-time Codex trust step. References claiming that Codex lacks working or +blocked events, or that integrations compete for one `notify` program, will be +removed. + +## Testing + +Tests will cover: + +- creation and idempotent merging of Codex `hooks.json`; +- preservation of existing user hooks and unrelated top-level fields; +- coexistence of dash and herdr handlers regardless of registration order; +- preservation of user-defined `notify` commands; +- removal of only the two legacy VibePod `notify` commands; +- soft failure for malformed `hooks.json` and `config.toml`; +- stdin parsing and state mapping for every supported Codex event; +- session metadata reporting to herdr; +- doctor recognition of lifecycle-hook registration; and +- unchanged behavior for non-Codex agents. + +The focused dash, herdr, doctor, run, and task tests will run with an isolated +`VP_CONFIG_DIR`, followed by the full unit suite, Ruff formatting/linting, and +mypy. + +## Non-goals + +- Changing the `vibepod/codex` image or selecting a different image tag. +- Supporting Codex versions that expose `notify` but not lifecycle hooks. +- Combining dash and herdr into one dispatcher or making either integration + depend on the other. +- Changing lifecycle behavior for Claude, Copilot, or other agents. diff --git a/mkdocs.yml b/mkdocs.yml index 3f565ed..4f04dc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,7 @@ nav: - Overview: overlays/index.md - Recipes: overlays/recipes.md - Herdr integration: herdr.md + - Dash integration: dash.md - Configuration: configuration.md - LLM Integration: llm.md - CLI Reference: cli-reference.md diff --git a/src/vibepod/cli.py b/src/vibepod/cli.py index be6f775..a0e3550 100644 --- a/src/vibepod/cli.py +++ b/src/vibepod/cli.py @@ -58,6 +58,10 @@ def run_command( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, detach: Annotated[ bool, typer.Option("-d", "--detach", help="Run container in background"), @@ -109,6 +113,7 @@ def run_command( no_overlay=no_overlay, rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, + no_dash=no_dash, detach=detach, env=env, publish=publish, @@ -159,6 +164,10 @@ def _alias( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, detach: Annotated[ bool, typer.Option("-d", "--detach", help="Run container in background"), @@ -212,6 +221,7 @@ def _alias( no_overlay=no_overlay, rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, + no_dash=no_dash, detach=detach, env=env, publish=publish, diff --git a/src/vibepod/commands/doctor.py b/src/vibepod/commands/doctor.py index 62d5a31..a55ed09 100644 --- a/src/vibepod/commands/doctor.py +++ b/src/vibepod/commands/doctor.py @@ -284,6 +284,35 @@ def _herdr_log_relpath(agent: str) -> str | None: }.get(agent) +def _herdr_registration(agent: str, cfg_dir: Path) -> str: + """How the agent is wired to report Herdr state, as a table cell.""" + from vibepod.core import herdr as herdr_core + + if agent == "claude": + settings = cfg_dir / "settings.json" + ok = settings.is_file() and "herdr-agent-state.sh" in settings.read_text( + encoding="utf-8", + errors="replace", + ) + return "settings.json" if ok else "MISSING" + if agent == "codex": + return _codex_registration(cfg_dir, herdr_core.CODEX_HOOK_COMMAND) + return "auto" + + +def _codex_registration(cfg_dir: Path, command: str) -> str: + """Render a Codex lifecycle-hook inspection as a diagnostic label.""" + from vibepod.core import codex_hooks + + status = codex_hooks.registration_status(cfg_dir, command) + return { + "missing": "MISSING hooks.json", + "malformed": "INVALID hooks.json", + "handler-absent": "MISSING handler", + "registered": "hooks.json", + }[status] + + def _herdr_agent_summary(profile: str) -> None: """One line per supported agent: integration, injection, registration, activity.""" from rich.table import Table @@ -322,24 +351,7 @@ def _herdr_agent_summary(profile: str) -> None: present = sum(1 for dest in dests if (cfg_dir / dest).is_file()) injected = "yes" if present == len(dests) else f"{present}/{len(dests)}" - if name == "claude": - settings = cfg_dir / "settings.json" - ok = settings.is_file() and "herdr-agent-state.sh" in settings.read_text( - encoding="utf-8", - errors="replace", - ) - registration = "settings.json" if ok else "MISSING" - elif name == "codex": - toml_path = cfg_dir / ".codex" / "config.toml" - ok = toml_path.is_file() and "herdr-agent-state.sh" in toml_path.read_text( - encoding="utf-8", - errors="replace", - ) - registration = "notify" if ok else "MISSING" - elif dests: - registration = "auto" - else: - registration = "-" + registration = _herdr_registration(name, cfg_dir) if dests else "-" activity = "-" log_rel = _herdr_log_relpath(name) @@ -518,13 +530,10 @@ def herdr_doctor( if not registered: failures += 1 if agent == "codex": - toml_path = cfg_dir / ".codex" / "config.toml" - registered = toml_path.is_file() and "herdr-agent-state.sh" in toml_path.read_text( - encoding="utf-8", - errors="replace", - ) + registration = _herdr_registration(agent, cfg_dir) + registered = registration == "hooks.json" (console.print if registered else warning)( - f" config.toml notify: {'registered' if registered else 'NOT REGISTERED'}", + f" lifecycle registration: {registration}", ) if not registered: failures += 1 @@ -552,7 +561,7 @@ def herdr_doctor( #: replays the exact in-container call path the agent itself would take. probe_payloads = { "claude": '{"hook_event_name":"Stop"}', - "codex": '{"type":"agent-turn-complete"}', + "codex": '{"hook_event_name":"Stop","session_id":"doctor"}', "copilot": '{"type":"stop"}', } probe_reported = False @@ -583,10 +592,7 @@ def herdr_doctor( hook_dest = herdr_core.BUILTIN_INTEGRATIONS[agent][0][1] hook_path = f"{spec.config_mount_path}/{hook_dest}" payload = probe_payloads[agent] - if agent == "codex": - shell_line = f"{hook_path} '{payload}'" - else: - shell_line = f"printf '%s' '{payload}' | {hook_path}" + shell_line = f"printf '%s' '{payload}' | {hook_path}" output = manager.client.containers.run( image, entrypoint=["/bin/sh"], @@ -671,3 +677,277 @@ def herdr_doctor( "herdr wiring looks healthy โ€” if the sidebar stays empty, the reports reach " "herdr but it does not surface them; check `herdr agent list` output above", ) + + +def _dash_agent_summary(profile: str, config: dict[str, Any]) -> None: + """One line per supported agent: integration, injection, registration, activity.""" + from rich.table import Table + + from vibepod.constants import SUPPORTED_AGENTS + from vibepod.core import dash as dash_core + + dash_cfg = config.get("dash") + custom = (dash_cfg or {}).get("integrations", {}) if isinstance(dash_cfg, dict) else {} + + table = Table(title="dash integration per agent") + for column in ("agent", "integration", "injected", "registration", "last activity"): + table.add_column(column) + + for name in SUPPORTED_AGENTS: + builtin = dash_core.BUILTIN_INTEGRATIONS.get(name, []) + custom_entries = custom.get(name) or [] + if builtin and custom_entries: + integration = f"built-in +{len(custom_entries)} custom" + elif builtin: + integration = "built-in" + elif custom_entries: + integration = f"custom ({len(custom_entries)})" + else: + # Still visible on the board: `vp run` reports start and stop itself. + integration = "start/stop only" + + cfg_dir = agent_config_dir(name, profile) + dests = [dest for _, dest in builtin] + [ + str(entry.get("dest")) for entry in custom_entries if isinstance(entry, dict) + ] + if not dests: + injected = "n.a." + else: + present = sum(1 for dest in dests if (cfg_dir / dest).is_file()) + injected = "yes" if present == len(dests) else f"{present}/{len(dests)}" + + registration = _dash_registration(name, cfg_dir) if dests else "-" + + activity = "-" + log_path = cfg_dir / dash_core.HOOK_LOG_NAME + if log_path.is_file(): + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + if lines: + activity = lines[-1][:70] + + table.add_row(name, integration, injected, registration, activity) + + console.print(table) + + +def _dash_registration(agent: str, cfg_dir: Path) -> str: + """How the agent is wired to call the hook, as a table cell.""" + from vibepod.core import dash as dash_core + + marker = "dash-agent-state.sh" + if agent == "claude": + settings = cfg_dir / "settings.json" + ok = settings.is_file() and marker in settings.read_text( + encoding="utf-8", + errors="replace", + ) + return "settings.json" if ok else "MISSING" + if agent == "codex": + return _codex_registration(cfg_dir, dash_core.CODEX_HOOK_COMMAND) + return "auto" + + +def _dash_reachable(url: str) -> tuple[bool, str, bool]: + """GET {url}/healthz; (ok, detail, hostname-does-not-resolve).""" + import urllib.error + import urllib.request + + from vibepod.core import dash as dash_core + + try: + with urllib.request.urlopen(f"{url}/healthz", timeout=5) as response: + body = response.read(200).decode("utf-8", errors="replace").strip() + return True, f"HTTP {response.status} {body}", False + except urllib.error.HTTPError as exc: + return False, f"HTTP {exc.code}", False + except (urllib.error.URLError, OSError) as exc: + return False, str(exc), dash_core.is_name_resolution_error(exc) + + +@app.command("dash") +def dash_doctor( + agent: Annotated[ + str | None, + typer.Argument(help="Agent to inspect in depth; omit for an all-agents summary"), + ] = None, + profile: Annotated[ + str | None, + typer.Option("--profile", help="Credential profile to inspect (see `vp profile list`)"), + ] = None, + workspace: Annotated[ + Path, + typer.Option("-w", "--workspace", help="Workspace the probe reports for"), + ] = Path("."), +) -> None: + """Diagnose VibePod Dash reporting end to end. + + Without an agent: resolved configuration, a reachability check and a + per-agent summary. With an agent: the injected files, their registration, + the hook trace log, a live host-side report and โ€” when a container runtime + is available โ€” the exact in-container hook call, which is what proves the + dashboard URL is reachable from inside the container. + """ + from vibepod.constants import SUPPORTED_AGENTS + from vibepod.core import dash as dash_core + from vibepod.core.agents import effective_agent_image, get_agent_spec, resolve_agent_name + from vibepod.core.config import get_config + from vibepod.core.launch import host_user as _host_user + + if agent is not None: + resolved = resolve_agent_name(agent) + if resolved is None: + error(f"Unknown agent '{agent}'. Supported: {', '.join(SUPPORTED_AGENTS)}") + raise typer.Exit(1) + agent = resolved + + config = get_config() + try: + active_profile = resolve_profile(profile, config) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc + + workspace_path = workspace.expanduser().resolve() + failures = 0 + + console.print("[bold]Configuration[/bold]") + if not dash_core.dash_enabled(config): + warning(" disabled by config (dash: false)") + url = dash_core.resolve_url(config) + if url is None: + error(" no dashboard URL (set VPDASH_URL or dash.url in the config)") + console.print(" see https://github.com/VibePod/vibepod-dash to run one") + raise typer.Exit(1) + host_url = dash_core.usable_host_url(url) + console.print(f" configured: {url}") + if host_url != url: + console.print(f" host URL: {host_url} (fell back: '{url}' is container-only)") + else: + console.print(f" host URL: {host_url}") + console.print(f" container URL: {dash_core.resolve_container_url(config, url)}") + console.print(f" token: {'set' if dash_core.resolve_token(config) else 'not set'}") + + console.print() + console.print("[bold]Reachability from this host[/bold]") + ok, detail, unresolvable = _dash_reachable(host_url) + if ok: + success(f" {detail}") + else: + error(f" {detail}") + if unresolvable: + warning( + " โ†’ this looks like a container-network name. dash.url is the URL the " + "CLI itself posts to; put the container-side name in dash.container_url.", + ) + failures += 1 + + if agent is None: + console.print() + _dash_agent_summary(active_profile, config) + console.print() + if failures: + error(f"{failures} problem(s) found") + raise typer.Exit(1) + console.print("Deep-dive one agent with `vp doctor dash `") + return + + target = dash_core.make_target(agent, workspace_path, config) + assert target is not None # a URL was resolved above + cfg_dir = agent_config_dir(agent, active_profile) + spec = get_agent_spec(agent) + + console.print() + console.print(f"[bold]Injected files[/bold] ({cfg_dir})") + entries = dash_core.BUILTIN_INTEGRATIONS.get(agent, []) + if not entries: + warning(f" no built-in hook for {agent}; it reports container start/stop only") + for _, dest_rel in entries: + dest = cfg_dir / dest_rel + if dest.is_file(): + console.print(f" {dest_rel}: {_format_mtime(dest)}") + else: + warning(f" {dest_rel}: MISSING (run `vp run {agent}` once to inject it)") + + if entries: + console.print() + console.print("[bold]Registration[/bold]") + console.print(f" {_dash_registration(agent, cfg_dir)}") + + console.print() + console.print("[bold]Hook trace log[/bold]") + log_path = cfg_dir / dash_core.HOOK_LOG_NAME + if not log_path.is_file(): + warning(f" {log_path} missing โ€” hooks never fired in the container") + else: + console.print(f" {log_path} (last 10 lines):") + for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-10:]: + console.print(f" {line}") + + console.print() + console.print("[bold]Host-side live report[/bold] (watch the board)") + if dash_core.report( + target, + "idle", + event="doctor", + message="probe from vp doctor dash", + cwd=workspace_path, + ): + success(f" reported '{target.name}' as idle") + else: + failures += 1 + + console.print() + console.print("[bold]Container-side probe[/bold]") + #: hook payload per sh-hook agent; the probe replays the exact call the + #: agent itself would make, which is what exercises the container URL. + probe_payloads = { + "claude": '{"hook_event_name":"Stop"}', + "codex": '{"hook_event_name":"Stop","session_id":"doctor"}', + "copilot": '{"type":"stop"}', + } + if agent not in probe_payloads: + warning(" skipped (sh-hook agents only)") + else: + try: + from vibepod.core.docker import DockerManager + + manager = DockerManager() + image = effective_agent_image(agent, config) + # The probe must sit on the network a real run uses: a + # container_url like http://vibepod-dash:8765 only resolves there. + network_name = str(config.get("network", "vibepod-network")) + manager.ensure_network(network_name) + hook_dest = dash_core.BUILTIN_INTEGRATIONS[agent][1][1] + hook_path = f"{spec.config_mount_path}/{hook_dest}" + payload = probe_payloads[agent] + shell_line = f"printf '%s' '{payload}' | {hook_path}" + output = manager.client.containers.run( + image, + entrypoint=["/bin/sh"], + command=["-c", f"{shell_line}; echo rc=$?"], + volumes={str(cfg_dir): {"bind": spec.config_mount_path, "mode": "rw"}}, + environment={ + **spec.extra_env, + **dash_core.container_env(target, spec.config_mount_path), + }, + user=_host_user(), + network=network_name, + extra_hosts={"host.docker.internal": "host-gateway"}, + remove=True, + stdout=True, + stderr=True, + ) + text = output.decode("utf-8", errors="replace").strip() + console.print(f" {text or '(no output)'}") + console.print(" (check the log above for the reporter's own trace line)") + except Exception as exc: # noqa: BLE001 - the probe is best-effort + warning(f" skipped: {exc}") + + console.print() + if failures: + error(f"{failures} problem(s) found") + raise typer.Exit(1) + success( + "dash wiring looks healthy โ€” if a card stays stale, the agent is not " + "firing hooks; check the trace log above", + ) diff --git a/src/vibepod/commands/run.py b/src/vibepod/commands/run.py index 9274056..42dac9d 100644 --- a/src/vibepod/commands/run.py +++ b/src/vibepod/commands/run.py @@ -24,6 +24,21 @@ ) from vibepod.core.allowed_dirs import add_allowed_dir, is_dir_allowed, is_protected_dir from vibepod.core.config import get_config +from vibepod.core.dash import ( + AGENT_ID_LABEL as _DASH_ID_LABEL, +) +from vibepod.core.dash import ( + AGENT_LABEL as _DASH_AGENT_LABEL, +) +from vibepod.core.dash import ( + apply_dash_if_enabled as _apply_dash_if_enabled, +) +from vibepod.core.dash import ( + details as _dash_details, +) +from vibepod.core.dash import ( + report as _dash_report, +) from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag from vibepod.core.herdr import ( PANE_LABEL as _HERDR_PANE_LABEL, @@ -333,6 +348,10 @@ def run( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, detach: Annotated[ bool, typer.Option("-d", "--detach", help="Run container in background"), @@ -627,6 +646,20 @@ def run( for key, value in herdr_env.items(): merged_env.setdefault(key, value) + dash_target, dash_env = _apply_dash_if_enabled( + selected_agent, + config_dir, + workspace_path, + config, + config_mount_path=spec.config_mount_path, + no_dash=no_dash, + ) + # setdefault: explicit -e VPDASH_* overrides win, same as for herdr + for key, value in dash_env.items(): + merged_env.setdefault(key, value) + # Filled once the container exists, then reused by the stop report. + dash_details: dict[str, str] = {} + if paste_images: display = os.environ.get("DISPLAY", "") if not display: @@ -691,6 +724,10 @@ def run( container_user = _host_user() launch_labels = dict(herdr_labels) launch_labels["vibepod.profile"] = active_profile + if dash_target is not None: + # `vp stop` reads these back to mark the agent finished on the board. + launch_labels[_DASH_AGENT_LABEL] = dash_target.agent + launch_labels[_DASH_ID_LABEL] = dash_target.agent_id if proxy_policy_id is not None: launch_labels["vibepod.proxy-policy"] = proxy_policy_id try: @@ -728,8 +765,37 @@ def run( if herdr_volumes: _release_herdr_agent(selected_agent) _clear_herdr_metadata(selected_agent) + if dash_target is not None: + _dash_report( + dash_target, + "error", + event="container.start", + message="container exited immediately after start", + cwd=workspace_path, + ) raise typer.Exit(1) + if dash_target is not None: + # Everything the board cannot know on its own, sent once at start and + # kept on the card for the life of the session. + dash_details.update( + _dash_details( + workspace=workspace_path, + image=image, + profile=active_profile, + container=container.name, + vibepod=__version__, + ), + ) + _dash_report( + dash_target, + "idle", + event="container.start", + message=f"{selected_agent} started in {workspace_path.name}", + cwd=workspace_path, + data=dash_details, + ) + # Prefer the inspected bindings (they resolve ephemeral 0-port publishes to # the daemon-assigned port); fall back to the requested bindings when the # inspect payload has no Ports section. @@ -775,6 +841,8 @@ def run( if herdr_volumes: _release_herdr_agent(selected_agent) _clear_herdr_metadata(selected_agent) + if dash_target is not None: + _dash_report(dash_target, "done", event="container.stop", cwd=workspace_path) raise typer.Exit(1) success(f"Started {container.name}") return @@ -814,6 +882,15 @@ def run( if herdr_volumes: _release_herdr_agent(selected_agent) _clear_herdr_metadata(selected_agent) + if dash_target is not None: + _dash_report( + dash_target, + "error" if exit_reason == "error" else "done", + event="container.stop", + message=f"session ended ({exit_reason})", + cwd=workspace_path, + data=dash_details, + ) if selected_agent == "claude" and "setup-token" in passthrough_args and exit_reason == "normal": _capture_claude_setup_token(config_dir) diff --git a/src/vibepod/commands/stop.py b/src/vibepod/commands/stop.py index 9c7015d..1e35d8d 100644 --- a/src/vibepod/commands/stop.py +++ b/src/vibepod/commands/stop.py @@ -9,6 +9,10 @@ from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.agents import resolve_agent_name +from vibepod.core.config import get_config +from vibepod.core.dash import AGENT_ID_LABEL as DASH_ID_LABEL +from vibepod.core.dash import report as dash_report +from vibepod.core.dash import target_from_labels from vibepod.core.docker import DockerClientError, DockerManager from vibepod.core.herdr import PANE_LABEL, release_agent from vibepod.utils.console import error, success @@ -41,7 +45,7 @@ def stop( raise typer.Exit(EXIT_DOCKER_NOT_RUNNING) from exc if all_containers: - _release_herdr_entries(_managed_containers(manager)) + _release_agent_entries(_managed_containers(manager)) try: stopped = manager.stop_all(force=force) except DockerClientError as exc: @@ -53,7 +57,7 @@ def stop( assert target is not None resolved_agent = resolve_agent_name(target) if resolved_agent is not None: - _release_herdr_entries( + _release_agent_entries( c for c in _managed_containers(manager) if (getattr(c, "labels", {}) or {}).get("vibepod.agent") == resolved_agent @@ -71,7 +75,7 @@ def stop( except DockerClientError as exc: error(str(exc)) raise typer.Exit(1) from exc - _release_herdr_entries([container]) + _release_agent_entries([container]) success(f"Stopped {container.name}") @@ -85,6 +89,13 @@ def _managed_containers(manager: Any) -> list[Any]: return [] +def _release_agent_entries(containers: Iterable[Any]) -> None: + """Tell herdr and the dashboard that these containers are finished.""" + containers = list(containers) + _release_herdr_entries(containers) + _report_dash_stopped(containers) + + def _release_herdr_entries(containers: Iterable[Any]) -> None: """Clear herdr sidebar entries for containers started inside herdr panes.""" for container in containers: @@ -93,3 +104,17 @@ def _release_herdr_entries(containers: Iterable[Any]) -> None: agent = labels.get("vibepod.agent") if pane and agent: release_agent(agent, pane=pane) + + +def _report_dash_stopped(containers: Iterable[Any]) -> None: + """Mark dashboard cards done for containers started with dash wiring.""" + config: dict[str, Any] | None = None + for container in containers: + labels = getattr(container, "labels", {}) or {} + if not labels.get(DASH_ID_LABEL): + continue + if config is None: + config = get_config() + target = target_from_labels(labels, config) + if target is not None: + dash_report(target, "done", event="container.stop", message="stopped by vp stop") diff --git a/src/vibepod/commands/task.py b/src/vibepod/commands/task.py index 677801b..4a74937 100644 --- a/src/vibepod/commands/task.py +++ b/src/vibepod/commands/task.py @@ -17,7 +17,7 @@ from rich.table import Table from vibepod import __version__ -from vibepod.commands.stop import _release_herdr_entries +from vibepod.commands.stop import _release_agent_entries from vibepod.constants import EXIT_DOCKER_NOT_RUNNING from vibepod.core.agents import ( AGENT_SPECS, @@ -28,6 +28,11 @@ ) from vibepod.core.allowed_dirs import add_allowed_dir, is_dir_allowed, is_protected_dir from vibepod.core.config import get_config, get_config_root +from vibepod.core.dash import AGENT_ID_LABEL as DASH_ID_LABEL +from vibepod.core.dash import AGENT_LABEL as DASH_AGENT_LABEL +from vibepod.core.dash import apply_dash_if_enabled, target_from_labels +from vibepod.core.dash import details as dash_details +from vibepod.core.dash import report as dash_report from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag from vibepod.core.herdr import PANE_LABEL, apply_herdr_if_enabled from vibepod.core.launch import ( @@ -134,6 +139,7 @@ def _record_with_container_state( store: TaskStore, record: TaskRecord, state: dict[str, Any], + container: Any | None = None, ) -> TaskRecord: if record.status == TASK_STATUS_CANCELLED: return record @@ -145,7 +151,7 @@ def _record_with_container_state( and record.finished_at == finished_at ): return record - return ( + updated = ( store.update( record.id, status=status, @@ -155,6 +161,33 @@ def _record_with_container_state( ) or record ) + # Only reached the first time a task crosses into a terminal status (the + # unchanged-record shortcut above returns early on every later sync), so + # the dashboard sees exactly one finish report. + if container is not None and updated.status in TERMINAL_TASK_STATUSES: + _report_dash_finished(container, updated) + return updated + + +def _report_dash_finished(container: Any, record: TaskRecord, message: str | None = None) -> None: + """Mark a finished task done on the dashboard, when one is configured.""" + labels = getattr(container, "labels", {}) or {} + if not labels.get(DASH_ID_LABEL): + return + target = target_from_labels(labels, get_config()) + if target is None: + return + detail = f" (exit {record.exit_code})" if record.exit_code is not None else "" + dash_report( + target, + "error" if record.status == TASK_STATUS_FAILED else "done", + event="task.finished", + message=message or f"task {record.status}{detail}", + cwd=record.workspace, + # Status syncs run from `vp task list`; a down dashboard must not + # print a warning per task per listing. + quiet=True, + ) def _format_task_status(record: TaskRecord) -> str: @@ -237,7 +270,7 @@ def _enforce_task_timeout( state = container.attrs.get("State", {}) or {} if not isinstance(state, dict): state = {} - record = _record_with_container_state(store, record, state) + record = _record_with_container_state(store, record, state, container) except DockerClientError: if record.status not in TERMINAL_TASK_STATUSES: store.update( @@ -256,13 +289,14 @@ def _enforce_task_timeout( container.stop(timeout=10) except Exception as exc: # docker SDK raises APIError / DockerException warning(f"Failed to stop timed-out task {record.id[:12]}: {exc}") - store.update( + timed_out = store.update( record.id, status=TASK_STATUS_FAILED, exit_code=record.exit_code, started_at=record.started_at, finished_at=_utcnow(), ) + _report_dash_finished(container, timed_out or record, message="task timed out") @app.command("_watch-timeout", hidden=True) @@ -318,6 +352,10 @@ def task_create_command( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, ikwid: Annotated[ bool, typer.Option( @@ -343,6 +381,7 @@ def task_create_command( no_overlay=no_overlay, rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, + no_dash=no_dash, ikwid=ikwid, profile=profile, passthrough_args=_context_args(ctx), @@ -394,6 +433,10 @@ def task_run_command( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, ikwid: Annotated[ bool, typer.Option( @@ -419,6 +462,7 @@ def task_run_command( no_overlay=no_overlay, rebuild_overlay=rebuild_overlay, no_herdr=no_herdr, + no_dash=no_dash, ikwid=ikwid, profile=profile, passthrough_args=_context_args(ctx), @@ -465,6 +509,10 @@ def task_create( bool, typer.Option("--no-herdr", help="Skip herdr terminal-multiplexer wiring"), ] = False, + no_dash: Annotated[ + bool, + typer.Option("--no-dash", help="Skip VibePod Dash state reporting"), + ] = False, ikwid: Annotated[ bool, typer.Option( @@ -702,6 +750,17 @@ def task_create( for key, value in herdr_env.items(): merged_env.setdefault(key, value) + dash_target, dash_env = apply_dash_if_enabled( + selected, + config_dir, + workspace_path, + config, + config_mount_path=spec.config_mount_path, + no_dash=no_dash, + ) + for key, value in dash_env.items(): + merged_env.setdefault(key, value) + proxy_cfg = config.get("proxy", {}) proxy_enabled = bool(proxy_cfg.get("enabled", True)) proxy_ca_dir_value = str(proxy_cfg.get("ca_dir", "")).strip() @@ -760,6 +819,9 @@ def task_create( launch_labels["vibepod.profile"] = active_profile if proxy_policy_id is not None: launch_labels["vibepod.proxy-policy"] = proxy_policy_id + if dash_target is not None: + launch_labels[DASH_AGENT_LABEL] = dash_target.agent + launch_labels[DASH_ID_LABEL] = dash_target.agent_id try: container = manager.run_agent( agent=selected, @@ -792,6 +854,14 @@ def task_create( error("Container exited immediately after start.") if recent.strip(): print(recent) + if dash_target is not None: + dash_report( + dash_target, + "error", + event="task.start", + message="container exited immediately after start", + cwd=workspace_path, + ) raise typer.Exit(1) if network and network != network_name: @@ -841,6 +911,26 @@ def task_create( except Exception as cleanup_exc: warning(f"Container {container.name} may be orphaned: {cleanup_exc}") raise typer.Exit(1) from exc + if dash_target is not None: + # Reported here rather than right after start so the card carries the + # task id โ€” the handle for `vp task logs` / `vp task cancel`. A task is + # also head-down from its first second, unlike an interactive run. + dash_report( + dash_target, + "working", + event="task.start", + message=prompt, + cwd=workspace_path, + data=dash_details( + workspace=workspace_path, + image=image, + profile=active_profile, + container=container.name, + task=record.id, + vibepod=__version__, + ), + ) + success(f"Task started: {record.id}") info(f" container: {container.name}") if timeout_seconds is None: @@ -887,7 +977,7 @@ def task_list( state = container.attrs.get("State", {}) or {} if not isinstance(state, dict): state = {} - record = _record_with_container_state(store, record, state) + record = _record_with_container_state(store, record, state, container) display_status = _format_task_status(record) except DockerClientError: if record.status not in TERMINAL_TASK_STATUSES: @@ -989,7 +1079,7 @@ def task_status( state = container.attrs.get("State", {}) or {} if not isinstance(state, dict): state = {} - record = _record_with_container_state(store, record, state) + record = _record_with_container_state(store, record, state, container) payload = record.as_dict() except DockerClientError: payload = record.as_dict() @@ -1035,7 +1125,7 @@ def task_cancel( state = container.attrs.get("State", {}) or {} if not isinstance(state, dict): state = {} - record = _record_with_container_state(store, record, state) + record = _record_with_container_state(store, record, state, container) except DockerClientError: store.update( record.id, @@ -1067,7 +1157,7 @@ def task_cancel( state = container.attrs.get("State", {}) or {} if not isinstance(state, dict): state = {} - record = _record_with_container_state(store, record, state) + record = _record_with_container_state(store, record, state, container) else: error(f"Failed to cancel task {record.id[:12]}: {exc}") raise typer.Exit(1) from exc @@ -1078,7 +1168,7 @@ def task_cancel( error(f"Failed to remove created task container {record.id[:12]}: {exc}") raise typer.Exit(1) from exc - _release_herdr_entries([container]) + _release_agent_entries([container]) updated: TaskRecord | None if record.status in TERMINAL_TASK_STATUSES: @@ -1193,7 +1283,7 @@ def _remove_task_record( "Use --force to kill and remove, or wait for it to finish.", ) raise typer.Exit(1) - _release_herdr_entries([container]) + _release_agent_entries([container]) try: container.remove(force=True) except Exception as exc: # docker SDK raises APIError / DockerException diff --git a/src/vibepod/core/codex_hooks.py b/src/vibepod/core/codex_hooks.py new file mode 100644 index 0000000..a64ec4d --- /dev/null +++ b/src/vibepod/core/codex_hooks.py @@ -0,0 +1,168 @@ +"""Shared Codex lifecycle-hook registration for VibePod integrations.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Literal + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised on Python 3.10 CI + import tomli as tomllib + +from vibepod.utils.console import warning + +LIFECYCLE_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Stop", + "Interrupt", + "SessionEnd", +) + +LEGACY_NOTIFY_LINES = frozenset( + { + 'notify = ["/config/.codex/herdr-agent-state.sh"]', + 'notify = ["/config/.codex/dash-agent-state.sh"]', + }, +) + +RegistrationStatus = Literal["missing", "malformed", "handler-absent", "registered"] + + +def _entry(command: str) -> dict[str, Any]: + return {"hooks": [{"type": "command", "command": command}]} + + +def _commands(data: object, event: str) -> list[str]: + if not isinstance(data, dict) or not isinstance(data.get("hooks"), dict): + return [] + groups = data["hooks"].get(event) + if not isinstance(groups, list): + return [] + return [ + command + for group in groups + if isinstance(group, dict) + for nested in [group.get("hooks")] + if isinstance(nested, list) + for hook in nested + if isinstance(hook, dict) + and hook.get("type") == "command" + and isinstance(command := hook.get("command"), str) + ] + + +def _shape_error(hooks: dict[str, Any]) -> str | None: + """Describe the first invalid matcher-group shape, if any.""" + for event, groups in hooks.items(): + if not isinstance(groups, list): + return f"hooks[{event!r}] is not a list" + for group_index, group in enumerate(groups): + if not isinstance(group, dict): + return f"hooks[{event!r}][{group_index}] is not an object" + nested = group.get("hooks", []) + if not isinstance(nested, list): + return f"hooks[{event!r}][{group_index}].hooks is not a list" + for hook_index, hook in enumerate(nested): + if not isinstance(hook, dict): + return f"hooks[{event!r}][{group_index}].hooks[{hook_index}] is not an object" + return None + + +def _remove_legacy_notify(config_dir: Path, *, label: str) -> None: + config_path = config_dir / ".codex" / "config.toml" + if not config_path.is_file(): + return + try: + content = config_path.read_text(encoding="utf-8") + tomllib.loads(content) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + warning(f"{label}: codex config.toml is not valid TOML; legacy notify left untouched") + return + + lines = content.splitlines() + retained = [line for line in lines if line.strip() not in LEGACY_NOTIFY_LINES] + if retained == lines: + return + new_content = "\n".join(retained) + ("\n" if content.endswith("\n") and retained else "") + try: + config_path.write_text(new_content, encoding="utf-8") + except OSError as exc: + warning(f"{label}: could not remove legacy codex notify: {exc}") + + +def register(config_dir: Path, command: str, *, label: str) -> bool: + """Merge one VibePod command into all Codex lifecycle events.""" + path = config_dir / ".codex" / "hooks.json" + data: dict[str, Any] = {} + if path.is_file(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + warning(f"{label}: could not parse codex hooks.json, skipping hook registration") + return False + if not isinstance(loaded, dict): + warning(f"{label}: codex hooks.json is not an object, skipping hook registration") + return False + data = loaded + + hooks = data.setdefault("hooks", {}) + if not isinstance(hooks, dict): + warning(f"{label}: codex hooks.json 'hooks' is not an object, skipping registration") + return False + if shape_error := _shape_error(hooks): + warning(f"{label}: codex hooks.json {shape_error}, skipping registration") + return False + + changed = False + for event in LIFECYCLE_EVENTS: + groups = hooks.setdefault(event, []) + present = any( + hook.get("type") == "command" and hook.get("command") == command + for group in groups + for hook in group.get("hooks", []) + ) + if not present: + groups.append(_entry(command)) + changed = True + + if changed: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + warning(f"{label}: could not write codex hooks.json: {exc}") + return False + + _remove_legacy_notify(config_dir, label=label) + return True + + +def registration_status(config_dir: Path, command: str) -> RegistrationStatus: + """Inspect one command's complete lifecycle registration without warning.""" + path = config_dir / ".codex" / "hooks.json" + if not path.is_file(): + return "missing" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return "malformed" + if not isinstance(data, dict): + return "malformed" + hooks = data.get("hooks", {}) + if not isinstance(hooks, dict) or _shape_error(hooks): + return "malformed" + if all(command in _commands(data, event) for event in LIFECYCLE_EVENTS): + return "registered" + return "handler-absent" + + +def registered(config_dir: Path, command: str) -> bool: + """Return whether *command* is registered for every lifecycle event.""" + return registration_status(config_dir, command) == "registered" diff --git a/src/vibepod/core/dash.py b/src/vibepod/core/dash.py new file mode 100644 index 0000000..cf8234c --- /dev/null +++ b/src/vibepod/core/dash.py @@ -0,0 +1,479 @@ +"""VibePod Dash integration. + +`vp run` wires the container so the agent shows up on a +[vibepod-dash](https://github.com/VibePod/vibepod-dash) board: ``VPDASH_*`` env +is injected, the vendored reporter and hook scripts are copied into the agent's +config dir, and the CLI itself reports the container's start and stop โ€” so +agents without lifecycle hooks still appear on the board with a state. + +Everything soft-fails: an unreachable or misconfigured dashboard never blocks +a run. +""" + +from __future__ import annotations + +import hashlib +import importlib.resources +import json +import os +import socket +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from vibepod.core.codex_hooks import register as register_codex_lifecycle_hooks +from vibepod.core.hooksync import sync_integration_files +from vibepod.utils.console import info, warning + +ENV_URL = "VPDASH_URL" +ENV_TOKEN = "VPDASH_TOKEN" +ENV_CONTAINER_URL = "VPDASH_CONTAINER_URL" +#: Every VibePod container gets host.docker.internal mapped to the host gateway +#: (see core/docker.py), so a dashboard on the host is reachable under it even +#: though `localhost` inside the container is the container itself. +CONTAINER_HOST = "host.docker.internal" +LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"}) +#: Hook trace log, written inside the agent config dir (host-visible via the +#: config mount) and read back by `vp doctor dash`. +HOOK_LOG_NAME = "dash-hook.log" +#: Container labels carrying the dashboard identity of a run, so `vp stop` can +#: mark the agent finished from any terminal. +AGENT_ID_LABEL = "vibepod.dash.agent-id" +AGENT_LABEL = "vibepod.dash.agent" +REQUEST_TIMEOUT = 3 + +#: agent -> list of (path relative to resources, dest relative to the +#: agent config dir). Dests follow each agent's in-container home layout, the +#: same way the herdr integration does. +BUILTIN_INTEGRATIONS: dict[str, list[tuple[str, str]]] = { + "claude": [ + ("dash/vpdash-report.sh", "hooks/vpdash-report.sh"), + ("dash/claude/dash-agent-state.sh", "hooks/dash-agent-state.sh"), + ], + "codex": [ + ("dash/vpdash-report.sh", ".codex/vpdash-report.sh"), + ("codex/dash-agent-state.sh", ".codex/dash-agent-state.sh"), + ], + "copilot": [ + ("dash/vpdash-report.sh", ".copilot/hooks/vpdash-report.sh"), + ("dash/copilot/dash-agent-state.sh", ".copilot/hooks/dash-agent-state.sh"), + ], +} + + +@dataclass(frozen=True) +class DashTarget: + """A dashboard, as seen from both sides of the container boundary.""" + + #: URL the CLI itself posts to. + host_url: str + #: The same dashboard, addressed from inside the container. + container_url: str + token: str | None + agent: str + agent_id: str + name: str + + +def _dash_section(config: dict[str, Any]) -> dict[str, Any]: + value = config.get("dash") + return value if isinstance(value, dict) else {} + + +def dash_enabled(config: dict[str, Any]) -> bool: + """Config gate: ``dash: false`` or ``dash: {enabled: false}`` disables.""" + value = config.get("dash", True) + if isinstance(value, dict): + return bool(value.get("enabled", True)) + return bool(value) + + +def resolve_url(config: dict[str, Any]) -> str | None: + """Dashboard URL from the environment, else config ``dash.url``.""" + raw = os.environ.get(ENV_URL) or str(_dash_section(config).get("url", "") or "") + raw = raw.strip().rstrip("/") + if not raw: + return None + if "://" not in raw: + raw = f"http://{raw}" + return raw + + +def resolve_token(config: dict[str, Any]) -> str | None: + """Ingest token from the environment, else config ``dash.token``.""" + raw = os.environ.get(ENV_TOKEN) or str(_dash_section(config).get("token", "") or "") + return raw.strip() or None + + +def container_url(url: str) -> str: + """Rewrite a loopback dashboard URL to one the container can reach. + + ``http://localhost:8765`` on the host is the container itself from inside, + which is the single most common way to wire this up wrong. + """ + parts = urllib.parse.urlsplit(url) + if (parts.hostname or "").lower() not in LOOPBACK_HOSTS: + return url + netloc = CONTAINER_HOST if parts.port is None else f"{CONTAINER_HOST}:{parts.port}" + return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def resolve_container_url(config: dict[str, Any], host_url: str) -> str: + """The dashboard URL to hand the container. + + Defaults to the loopback rewrite above, which suits a dash server whose + port is published on the host. Set ``dash.container_url`` (or + ``VPDASH_CONTAINER_URL``) when the container reaches it some other way โ€” + e.g. ``http://vibepod-dash:8765`` when the dash container joins the + VibePod network, where the host name would not resolve for the CLI's own + reports. + """ + raw = os.environ.get(ENV_CONTAINER_URL) or str( + _dash_section(config).get("container_url", "") or "", + ) + raw = raw.strip().rstrip("/") + if not raw: + return container_url(host_url) + return raw if "://" in raw else f"http://{raw}" + + +#: Resolved host-side URLs, so the probe below runs once per process. +_host_url_cache: dict[str, str] = {} + + +def _resolves(hostname: str) -> bool: + try: + socket.getaddrinfo(hostname, None) + except (socket.gaierror, UnicodeError): + return False + return True + + +def _answers(url: str) -> bool: + try: + with urllib.request.urlopen(f"{url}/healthz", timeout=2): + return True + except (urllib.error.URLError, OSError, ValueError): + return False + + +def usable_host_url(url: str) -> str: + """The configured URL, or a loopback equivalent when it is container-only. + + ``http://vibepod-dash:8765`` is the natural thing to configure once the + dashboard sits on the VibePod network โ€” but that name only resolves for + the agents, not for the CLI reporting container start and stop from the + host. Rather than fail, look for the same dashboard on the loopback + address (its port is published) and use that for the CLI's own reports. + Never guesses silently: the fallback is only adopted when it answers. + """ + cached = _host_url_cache.get(url) + if cached is not None: + return cached + + resolved = url + parts = urllib.parse.urlsplit(url) + hostname = parts.hostname or "" + if hostname and not _resolves(hostname): + port = parts.port or (443 if parts.scheme == "https" else 80) + fallback = f"http://127.0.0.1:{port}" + if _answers(fallback): + info(f"dash: '{hostname}' is container-only; reporting to {fallback} from the host") + resolved = fallback + _host_url_cache[url] = resolved + return resolved + + +def agent_id(agent: str, workspace: Path, host: str) -> str: + """Stable dashboard id for *agent* working in *workspace* on *host*. + + Deterministic on purpose: re-running an agent in the same checkout updates + the card it had before instead of stacking up a new one every session. + Override with ``VPDASH_AGENT_ID`` when you want one card per run. + """ + seed = f"{host}|{agent}|{workspace}" + return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16] + + +def display_name(agent: str, workspace: Path) -> str: + """Card title; the ``vp:`` prefix marks a VibePod-run agent, as herdr does.""" + return f"vp:{agent} ยท {workspace.name or workspace}" + + +def make_target(agent: str, workspace: Path, config: dict[str, Any]) -> DashTarget | None: + """Build the target for this run, or None when no dashboard is configured.""" + url = resolve_url(config) + if url is None: + return None + host = os.environ.get("VPDASH_HOST") or socket.gethostname() + return DashTarget( + # Container side first: it is derived from what was configured, not + # from the loopback URL the host side may fall back to. + host_url=usable_host_url(url), + container_url=resolve_container_url(config, url), + token=resolve_token(config), + agent=agent, + agent_id=os.environ.get("VPDASH_AGENT_ID") or agent_id(agent, workspace, host), + name=os.environ.get("VPDASH_AGENT_NAME") or display_name(agent, workspace), + ) + + +def container_env(target: DashTarget, config_mount_path: str) -> dict[str, str]: + """Environment the in-container hooks need to report to *target*.""" + env = { + ENV_URL: target.container_url, + "VPDASH_AGENT": target.agent, + "VPDASH_AGENT_ID": target.agent_id, + "VPDASH_AGENT_NAME": target.name, + "VPDASH_HOST": os.environ.get("VPDASH_HOST") or socket.gethostname(), + "VPDASH_LOG": f"{config_mount_path.rstrip('/')}/{HOOK_LOG_NAME}", + } + if target.token: + env[ENV_TOKEN] = target.token + return env + + +def resource_root() -> Path: + """Root of the packaged integration files used by dash.""" + return Path(str(importlib.resources.files("vibepod"))) / "resources" + + +def sync_dash_files(agent: str, config_dir: Path, config: dict[str, Any]) -> int: + """Copy dash integration files for *agent* into its config dir. + + Built-in vendored files first, then user entries from config + ``dash.integrations.`` (list of {source, dest}). + """ + entries = _dash_section(config).get("integrations", {}) + custom = entries.get(agent, []) if isinstance(entries, dict) else [] + return sync_integration_files( + label="dash", + root=resource_root(), + builtin=BUILTIN_INTEGRATIONS.get(agent, []), + config_dir=config_dir, + custom=custom or [], + agent=agent, + ) + + +_DASH_MARKER = "dash-agent-state.sh" +_CLAUDE_EVENTS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Notification", + "Stop", + "SessionEnd", +) +CODEX_HOOK_COMMAND = "/config/.codex/dash-agent-state.sh" + + +def _claude_hook_entry() -> dict[str, Any]: + return { + "hooks": [ + {"type": "command", "command": '"$CLAUDE_CONFIG_DIR"/hooks/dash-agent-state.sh'}, + ], + } + + +def register_claude_hooks(config_dir: Path) -> None: + """Merge the dash hook into claude's settings.json, idempotently.""" + settings_path = config_dir / "settings.json" + settings: dict[str, Any] = {} + if settings_path.is_file(): + try: + loaded = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + warning("dash: could not parse claude settings.json, skipping hook registration") + return + if isinstance(loaded, dict): + settings = loaded + hooks = settings.setdefault("hooks", {}) + if not isinstance(hooks, dict): + warning("dash: claude settings.json 'hooks' is not an object, skipping") + return + changed = False + for event in _CLAUDE_EVENTS: + entries = hooks.setdefault(event, []) + if not isinstance(entries, list): + warning(f"dash: claude settings.json hooks['{event}'] is not a list, skipping") + continue + present = any( + _DASH_MARKER in hook.get("command", "") + for item in entries + if isinstance(item, dict) + for hook in item.get("hooks", []) + if isinstance(hook, dict) + ) + if not present: + entries.append(_claude_hook_entry()) + changed = True + if changed: + settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + + +def register_codex_hooks(config_dir: Path) -> None: + """Register dash for Codex lifecycle events, preserving other hooks.""" + register_codex_lifecycle_hooks(config_dir, CODEX_HOOK_COMMAND, label="dash") + + +def details(**fields: Any) -> dict[str, str]: + """Build a report's ``data`` block, dropping whatever is unset. + + This is where the context only VibePod has โ€” the workspace it mounted, the + image it resolved, the profile, the container it can be attached to, the + task id โ€” reaches the board, which shows it on the agent's card. + """ + out: dict[str, str] = {} + for key, value in fields.items(): + if value is None or value == "": + continue + out[key] = str(value) + return out + + +def report( + target: DashTarget, + state: str, + *, + event: str | None = None, + message: str | None = None, + cwd: Path | str | None = None, + data: dict[str, str] | None = None, + quiet: bool = False, +) -> bool: + """POST one state report to the dashboard. Never raises.""" + payload: dict[str, Any] = { + "agent": target.agent, + "agent_id": target.agent_id, + "state": state, + "host": os.environ.get("VPDASH_HOST") or socket.gethostname(), + } + if data: + payload["data"] = data + if target.name: + payload["name"] = target.name + if event: + payload["event"] = event + if message: + payload["message"] = message + if cwd: + payload["cwd"] = str(cwd) + + headers = {"Content-Type": "application/json"} + if target.token: + headers["Authorization"] = f"Bearer {target.token}" + request = urllib.request.Request( + f"{target.host_url}/api/v1/events", + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT): + return True + except (urllib.error.URLError, OSError, ValueError) as exc: + if not quiet: + _warn_once(target.host_url, state, exc) + return False + + +#: Host URLs already complained about, so a run that reports start and stop +#: does not print the same failure twice. +_warned_urls: set[str] = set() + + +def reset_state() -> None: + """Drop the warn-once and host-URL caches (tests, long-lived processes).""" + _warned_urls.clear() + _host_url_cache.clear() + + +def is_name_resolution_error(exc: BaseException) -> bool: + """True when *exc* means the hostname itself could not be resolved.""" + return isinstance(exc, socket.gaierror) or isinstance( + getattr(exc, "reason", None), + socket.gaierror, + ) + + +def _warn_once(host_url: str, state: str, exc: BaseException) -> None: + if host_url in _warned_urls: + return + _warned_urls.add(host_url) + warning(f"dash: could not report '{state}' to {host_url}: {exc}") + if getattr(exc, "code", None) in (401, 403): + warning( + "dash: the dashboard rejected the token. Set dash.token (or VPDASH_TOKEN) to " + "its ingest token โ€” the server prints it on start, e.g. " + "`docker compose logs dash | grep 'ingest token'`.", + ) + if is_name_resolution_error(exc): + # A container-network name in dash.url that usable_host_url() could + # not find a published loopback equivalent for. + warning( + f"dash: '{urllib.parse.urlsplit(host_url).hostname}' does not resolve on this " + "host and nothing answered on the same port locally. Publish the dashboard's " + "port, or set dash.url to a URL the CLI can reach and keep the " + "container-side name in dash.container_url.", + ) + + +def apply_dash_if_enabled( + agent: str, + config_dir: Path, + workspace: Path, + config: dict[str, Any], + *, + config_mount_path: str, + no_dash: bool, +) -> tuple[DashTarget | None, dict[str, str]]: + """Wire the dash integration for this run. Never raises. + + Returns ``(target, env)``; both empty when no dashboard is configured, the + config disables it, or ``--no-dash`` was passed. + """ + if no_dash or not dash_enabled(config): + return None, {} + target = make_target(agent, workspace, config) + if target is None: + return None, {} + try: + synced = sync_dash_files(agent, config_dir, config) + if agent == "claude": + register_claude_hooks(config_dir) + elif agent == "codex": + register_codex_hooks(config_dir) + except Exception as exc: # noqa: BLE001 - dash problems must never block a run + warning(f"dash: could not prepare integration files: {exc}") + return target, container_env(target, config_mount_path) + detail = f"{synced} hook file(s)" if synced else "start/stop reports only" + info(f"dash: reporting {target.name} to {target.host_url} ({detail})") + return target, container_env(target, config_mount_path) + + +def target_from_labels(labels: dict[str, str], config: dict[str, Any]) -> DashTarget | None: + """Rebuild a target from a container's labels, for `vp stop`. + + The name is left empty: the dashboard keeps the one it already has rather + than being renamed by a bare stop report. + """ + agent = labels.get(AGENT_LABEL) + dash_agent_id = labels.get(AGENT_ID_LABEL) + if not agent or not dash_agent_id: + return None + url = resolve_url(config) + if url is None or not dash_enabled(config): + return None + return DashTarget( + host_url=usable_host_url(url), + container_url=resolve_container_url(config, url), + token=resolve_token(config), + agent=agent, + agent_id=dash_agent_id, + name="", + ) diff --git a/src/vibepod/core/herdr.py b/src/vibepod/core/herdr.py index a346834..8187462 100644 --- a/src/vibepod/core/herdr.py +++ b/src/vibepod/core/herdr.py @@ -13,16 +13,12 @@ import json import os import shutil -import stat import sys from pathlib import Path from typing import Any -if sys.version_info >= (3, 11): - import tomllib -else: # pragma: no cover - exercised on Python 3.10 CI - import tomli as tomllib - +from vibepod.core.codex_hooks import register as register_codex_lifecycle_hooks +from vibepod.core.hooksync import sync_integration_files from vibepod.utils.console import info, warning DEFAULT_SOCKET = Path("~/.config/herdr/herdr.sock") @@ -107,18 +103,6 @@ def resource_root() -> Path: return Path(str(importlib.resources.files("vibepod"))) / "resources" / "herdr" -def _copy_into(config_dir: Path, dest_rel: str, content: bytes, executable: bool) -> bool: - dest = (config_dir / dest_rel).resolve() - if config_dir.resolve() not in dest.parents: - warning(f"herdr: destination '{dest_rel}' escapes the agent config dir, skipping") - return False - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(content) - if executable: - dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - return True - - def sync_herdr_files(agent: str, config_dir: Path, config: dict[str, Any]) -> int: """Copy herdr integration files for *agent* into its config dir. @@ -127,31 +111,16 @@ def sync_herdr_files(agent: str, config_dir: Path, config: dict[str, Any]) -> in VibePod-owned dests are overwritten each run; other files untouched. Returns the number of files synced. """ - synced = 0 - root = resource_root() - for resource_rel, dest_rel in BUILTIN_INTEGRATIONS.get(agent, []): - source = root / resource_rel - if not source.is_file(): - warning(f"herdr: packaged resource missing: {resource_rel}") - continue - executable = resource_rel.endswith(".sh") - if _copy_into(config_dir, dest_rel, source.read_bytes(), executable): - synced += 1 - herdr_cfg = config.get("herdr") entries = (herdr_cfg or {}).get("integrations", {}) if isinstance(herdr_cfg, dict) else {} - for entry in entries.get(agent, []) or []: - if not isinstance(entry, dict) or "source" not in entry or "dest" not in entry: - warning(f"herdr: invalid integration entry for '{agent}': {entry!r}") - continue - source = Path(str(entry["source"])).expanduser() - if not source.is_file(): - warning(f"herdr: integration source not found: {source}") - continue - executable = os.access(source, os.X_OK) - if _copy_into(config_dir, str(entry["dest"]), source.read_bytes(), executable): - synced += 1 - return synced + return sync_integration_files( + label="herdr", + root=resource_root(), + builtin=BUILTIN_INTEGRATIONS.get(agent, []), + config_dir=config_dir, + custom=entries.get(agent, []) or [], + agent=agent, + ) _HERDR_MARKER = "herdr-agent-state.sh" @@ -164,7 +133,7 @@ def sync_herdr_files(agent: str, config_dir: Path, config: dict[str, Any]) -> in "Stop", "SessionEnd", ) -_CODEX_NOTIFY_LINE = 'notify = ["/config/.codex/herdr-agent-state.sh"]' +CODEX_HOOK_COMMAND = "/config/.codex/herdr-agent-state.sh" def _claude_hook_entry() -> dict[str, Any]: @@ -214,50 +183,9 @@ def register_claude_hooks(config_dir: Path) -> None: settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") -def register_codex_notify(config_dir: Path) -> None: - """Point codex's notify program at our hook script, idempotently. - - The line must live in the TOML root table, so it is inserted at the top - of the file โ€” appending would place it inside the last ``[section]``. - A previously misplaced line (early VibePod versions appended) is moved. - """ - config_path = config_dir / ".codex" / "config.toml" - content = "" - if config_path.is_file(): - try: - content = config_path.read_text(encoding="utf-8") - except OSError: - warning("herdr: could not read codex config.toml, skipping notify registration") - return - - lines = content.splitlines() - marker_at = next( - (i for i, line in enumerate(lines) if line.strip() == _CODEX_NOTIFY_LINE), - None, - ) - if marker_at is not None: - if not any(line.lstrip().startswith("[") for line in lines[:marker_at]): - return - del lines[marker_at] - content = "\n".join(lines) + ("\n" if lines else "") - - try: - parsed = tomllib.loads(content) - except tomllib.TOMLDecodeError: - warning("herdr: codex config.toml is not valid TOML, skipping notify registration") - return - if "notify" in parsed: - warning("herdr: codex config.toml already sets 'notify', leaving it untouched") - return - - new_content = _CODEX_NOTIFY_LINE + "\n" + content - try: - tomllib.loads(new_content) - except tomllib.TOMLDecodeError: - warning("herdr: notify registration would corrupt codex config.toml, skipping") - return - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(new_content, encoding="utf-8") +def register_codex_hooks(config_dir: Path) -> None: + """Register herdr for Codex lifecycle events, preserving other hooks.""" + register_codex_lifecycle_hooks(config_dir, CODEX_HOOK_COMMAND, label="herdr") #: Container label carrying the herdr pane a run was started in, so @@ -322,9 +250,9 @@ def reexec_with_agent_hint(agent: str, config: dict[str, Any], *, no_herdr: bool Herdr reads the foreground process's /proc//environ, which is a snapshot taken at exec time โ€” setting os.environ later is invisible to it. The hint lets herdr use the named agent's screen manifest even - though the pane runs `vp` (agents like codex have no working/blocked - hook events, so screen detection is their only state source). No-op - when the hint already matches (post-re-exec) or herdr is inactive. + though the pane runs `vp`, including before an agent's lifecycle hooks + begin reporting state. No-op when the hint already matches (post-re-exec) + or herdr is inactive. """ if no_herdr or not herdr_enabled(config) or not herdr_active(): return @@ -460,7 +388,7 @@ def apply_herdr_if_enabled( if agent == "claude": register_claude_hooks(config_dir) elif agent == "codex": - register_codex_notify(config_dir) + register_codex_hooks(config_dir) except Exception as exc: # noqa: BLE001 - herdr problems must never block a run warning(f"herdr: could not prepare integration files: {exc}") return volumes, env diff --git a/src/vibepod/core/hooksync.py b/src/vibepod/core/hooksync.py new file mode 100644 index 0000000..6991f80 --- /dev/null +++ b/src/vibepod/core/hooksync.py @@ -0,0 +1,86 @@ +"""Shared plumbing for the hook-file integrations (herdr, dash). + +Both integrations do the same thing with different payloads: copy VibePod-owned +scripts into an agent's config directory, then let the user add their own via +``.integrations.`` config entries. The copy is idempotent โ€” +VibePod-owned destinations are overwritten on every run, everything else is +left alone โ€” and never raises: a broken integration must not block a run. +""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path +from typing import Any + +from vibepod.utils.console import warning + + +def copy_into( + config_dir: Path, + dest_rel: str, + content: bytes, + *, + executable: bool, + label: str, +) -> bool: + """Write *content* to ``config_dir/dest_rel``; False when it was refused.""" + dest = (config_dir / dest_rel).resolve() + if config_dir.resolve() not in dest.parents: + warning(f"{label}: destination '{dest_rel}' escapes the agent config dir, skipping") + return False + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + if executable: + dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return True + + +def sync_integration_files( + *, + label: str, + root: Path, + builtin: list[tuple[str, str]], + config_dir: Path, + custom: list[Any], + agent: str, +) -> int: + """Copy the built-in files for *agent*, then the user's own. Returns the count. + + *builtin* maps packaged resource paths (relative to *root*) to destinations + relative to the agent config dir; *custom* holds ``{source, dest}`` entries + read from the user's config. + """ + synced = 0 + for resource_rel, dest_rel in builtin: + source = root / resource_rel + if not source.is_file(): + warning(f"{label}: packaged resource missing: {resource_rel}") + continue + if copy_into( + config_dir, + dest_rel, + source.read_bytes(), + executable=resource_rel.endswith(".sh"), + label=label, + ): + synced += 1 + + for entry in custom or []: + if not isinstance(entry, dict) or "source" not in entry or "dest" not in entry: + warning(f"{label}: invalid integration entry for '{agent}': {entry!r}") + continue + source = Path(str(entry["source"])).expanduser() + if not source.is_file(): + warning(f"{label}: integration source not found: {source}") + continue + if copy_into( + config_dir, + str(entry["dest"]), + source.read_bytes(), + executable=os.access(source, os.X_OK), + label=label, + ): + synced += 1 + return synced diff --git a/src/vibepod/resources/codex/dash-agent-state.sh b/src/vibepod/resources/codex/dash-agent-state.sh new file mode 100755 index 0000000..4ece1eb --- /dev/null +++ b/src/vibepod/resources/codex/dash-agent-state.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# Managed by VibePod โ€” reports Codex lifecycle hook events to VibePod Dash. +# Receives one JSON object on stdin and always exits 0 so a dashboard outage +# never disturbs the agent. +set -u + +log() { + [ -n "${VPDASH_LOG:-}" ] || return 0 + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo -)" "$1" \ + >>"$VPDASH_LOG" 2>/dev/null || true +} + +url="${VPDASH_URL:-}" +if [ -z "$url" ]; then + log "skip reason=VPDASH_URL unset" + exit 0 +fi + +payload=$(cat 2>/dev/null || true) + +# python3 when the image has it (handles nested objects and escapes), a +# newline-flattened sed otherwise. +if command -v python3 >/dev/null 2>&1; then + field() { + printf '%s' "$payload" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +value = data +for part in sys.argv[1].split("."): + if not isinstance(value, dict): + value = None + break + value = value.get(part) +if isinstance(value, (dict, list)): + value = json.dumps(value) +print("" if value is None else str(value).replace("\n", " ")[:300]) +' "$1" 2>/dev/null + } +else + field() { + key=${1##*.} + printf '%s' "$payload" | tr '\n' ' ' \ + | sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" \ + | cut -c1-300 + } +fi + +event=$(field hook_event_name) +session_id=$(field session_id) +cwd=$(field cwd) +[ -n "$cwd" ] || cwd="$PWD" + +case "$event" in + SessionStart) + state=idle + message="session started" + ;; + UserPromptSubmit) + state=working + message=$(field prompt) + ;; + PreToolUse|PostToolUse) + state=working + message=$(field tool_name) + ;; + PermissionRequest) + state=blocked + message=$(field tool_input.description) + [ -n "$message" ] || message="$(field tool_name) needs approval" + ;; + Stop) + state=idle + message=$(field last_assistant_message) + [ -n "$message" ] || message="waiting for you" + ;; + Interrupt) + state=idle + message="turn interrupted" + ;; + SessionEnd) + state=done + message="session ended" + ;; + *) + log "ignore event=${event:-?}" + exit 0 + ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +reporter="$script_dir/vpdash-report.sh" +if [ ! -x "$reporter" ]; then + log "skip event=$event reason=reporter missing at $reporter" + exit 0 +fi + +out=$( + VPDASH_AGENT="${VPDASH_AGENT:-codex}" "$reporter" \ + --state "$state" \ + --event "$event" \ + --message "$message" \ + --session "$session_id" \ + --cwd "$cwd" 2>&1 +) +rc=$? +log "report state=$state event=$event rc=$rc${out:+ out=$out}" + +exit 0 diff --git a/src/vibepod/resources/dash/README.md b/src/vibepod/resources/dash/README.md new file mode 100644 index 0000000..62cfa30 --- /dev/null +++ b/src/vibepod/resources/dash/README.md @@ -0,0 +1,10 @@ +# Vendored VibePod Dash clients + +These files are copied **verbatim** from `clients/` in +[VibePod/vibepod-dash](https://github.com/VibePod/vibepod-dash) and injected +into an agent's config directory by `vibepod.core.dash`. + +Do not edit them here in isolation: change them in vibepod-dash first (its +end-to-end tests run them against a real server), then copy the result back. +They may only assume POSIX `sh` and `curl` โ€” agent images often have neither +`node`, `jq` nor `python3` โ€” and must exit 0 on every failure path. diff --git a/src/vibepod/resources/dash/claude/dash-agent-state.sh b/src/vibepod/resources/dash/claude/dash-agent-state.sh new file mode 100755 index 0000000..da7192d --- /dev/null +++ b/src/vibepod/resources/dash/claude/dash-agent-state.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# Claude Code hook โ†’ VibePod Dash. Receives the hook payload as JSON on stdin +# and maps the lifecycle event to a dashboard state. Always exits 0 so a dash +# outage never disturbs the agent. +# +# Install with clients/install-claude-hooks.py, or register it manually for +# SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop +# and SessionEnd. Needs VPDASH_URL (and VPDASH_TOKEN) in the environment. +# +# Set VPDASH_LOG to a writable path to trace what the hook did (VibePod points +# it at the mounted agent config dir, so the log is readable from the host). +set -u + +log() { + [ -n "${VPDASH_LOG:-}" ] || return 0 + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo -)" "$1" \ + >>"$VPDASH_LOG" 2>/dev/null || true +} + +url="${VPDASH_URL:-}" +if [ -z "$url" ]; then + log "skip reason=VPDASH_URL unset" + exit 0 +fi + +payload=$(cat 2>/dev/null || true) + +# python3 when the image has it (handles nested objects and escapes), a +# newline-flattened sed otherwise. +if command -v python3 >/dev/null 2>&1; then + field() { + printf '%s' "$payload" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +value = data.get(sys.argv[1]) +if isinstance(value, (dict, list)): + value = json.dumps(value) +print("" if value is None else str(value).replace("\n", " ")[:300]) +' "$1" 2>/dev/null + } +else + field() { + printf '%s' "$payload" | tr '\n' ' ' \ + | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" \ + | cut -c1-300 + } +fi + +event=$(field hook_event_name) +session=$(field session_id) +cwd=$(field cwd) +[ -n "$cwd" ] || cwd="$PWD" + +case "$event" in + SessionStart) state=idle; message="session started" ;; + UserPromptSubmit) state=working; message=$(field prompt) ;; + PreToolUse) state=working; message=$(field tool_name) ;; + PostToolUse) state=working; message=$(field tool_name) ;; + Notification) state=blocked; message=$(field message) ;; + Stop) state=idle; message="waiting for you" ;; + SessionEnd) state=done; message="session ended" ;; + *) + log "ignore event=${event:-?}" + exit 0 + ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +reporter="$script_dir/vpdash-report.sh" +if [ ! -x "$reporter" ]; then + log "skip event=$event reason=reporter missing at $reporter" + exit 0 +fi + +out=$( + VPDASH_AGENT="${VPDASH_AGENT:-claude}" "$reporter" \ + --state "$state" \ + --event "$event" \ + --message "${message:-$event}" \ + --session "$session" \ + --cwd "$cwd" 2>&1 +) +rc=$? +log "report state=$state event=$event rc=$rc${out:+ out=$out}" + +exit 0 diff --git a/src/vibepod/resources/dash/codex/dash-agent-state.sh b/src/vibepod/resources/dash/codex/dash-agent-state.sh new file mode 100755 index 0000000..d1ce298 --- /dev/null +++ b/src/vibepod/resources/dash/codex/dash-agent-state.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Codex notify program โ†’ VibePod Dash. Codex passes the event as a JSON string +# argument (not on stdin). Register it in ~/.codex/config.toml: +# +# notify = ["/path/to/dash-agent-state.sh"] +# +# Needs VPDASH_URL (and VPDASH_TOKEN) in the environment. Set VPDASH_LOG to a +# writable path to trace what the hook did. Always exits 0. +set -u + +log() { + [ -n "${VPDASH_LOG:-}" ] || return 0 + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo -)" "$1" \ + >>"$VPDASH_LOG" 2>/dev/null || true +} + +url="${VPDASH_URL:-}" +if [ -z "$url" ]; then + log "skip reason=VPDASH_URL unset" + exit 0 +fi +payload="${1:-}" + +field() { + printf '%s' "$payload" | tr '\n' ' ' \ + | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" \ + | cut -c1-300 +} + +kind=$(field type) +case "$kind" in + agent-turn-complete) state=idle; message=$(field last-assistant-message) ;; + *) state=working; message="$kind" ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +reporter="$script_dir/vpdash-report.sh" +if [ ! -x "$reporter" ]; then + log "skip type=${kind:-?} reason=reporter missing at $reporter" + exit 0 +fi + +out=$( + VPDASH_AGENT="${VPDASH_AGENT:-codex}" "$reporter" \ + --state "$state" \ + --event "${kind:-notify}" \ + --message "${message:-${kind:-notify}}" \ + --session "$(field turn-id)" 2>&1 +) +rc=$? +log "report state=$state type=${kind:-?} rc=$rc${out:+ out=$out}" + +exit 0 diff --git a/src/vibepod/resources/dash/copilot/dash-agent-state.sh b/src/vibepod/resources/dash/copilot/dash-agent-state.sh new file mode 100755 index 0000000..1f4175e --- /dev/null +++ b/src/vibepod/resources/dash/copilot/dash-agent-state.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Copilot CLI hook โ†’ VibePod Dash. Receives the hook payload as JSON on stdin. +# The event vocabulary tolerates naming drift: matching is on substrings, and +# approval-ish events win over tool-ish ones (ToolApprovalRequest contains +# both). Needs VPDASH_URL (and VPDASH_TOKEN); set VPDASH_LOG to trace. +set -u + +log() { + [ -n "${VPDASH_LOG:-}" ] || return 0 + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo -)" "$1" \ + >>"$VPDASH_LOG" 2>/dev/null || true +} + +url="${VPDASH_URL:-}" +if [ -z "$url" ]; then + log "skip reason=VPDASH_URL unset" + exit 0 +fi + +payload=$(cat 2>/dev/null || true) +event=$(printf '%s' "$payload" | tr '\n' ' ' \ + | sed -n 's/.*"\(hook_event_name\|type\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p') + +case "$event" in + *[Nn]otif* | *[Pp]ermission* | *[Aa]pproval*) state=blocked ;; + *[Pp]rompt* | *[Tt]ool*) state=working ;; + *[Ss]top* | *[Ee]nd* | *[Cc]omplete*) state=idle ;; + *) + log "ignore event=${event:-?}" + exit 0 + ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +reporter="$script_dir/vpdash-report.sh" +if [ ! -x "$reporter" ]; then + log "skip event=$event reason=reporter missing at $reporter" + exit 0 +fi + +out=$( + VPDASH_AGENT="${VPDASH_AGENT:-copilot}" "$reporter" \ + --state "$state" --event "$event" --message "$event" 2>&1 +) +rc=$? +log "report state=$state event=$event rc=$rc${out:+ out=$out}" + +exit 0 diff --git a/src/vibepod/resources/dash/vpdash-report.sh b/src/vibepod/resources/dash/vpdash-report.sh new file mode 100755 index 0000000..84a9370 --- /dev/null +++ b/src/vibepod/resources/dash/vpdash-report.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Report one agent event to a VibePod Dash server. +# +# vpdash-report.sh --state working --event PreToolUse --message "Edit app.py" +# +# Environment: +# VPDASH_URL base URL of the dash server (required), e.g. http://dash.local:8765 +# VPDASH_TOKEN ingest token (required unless the server runs without one) +# VPDASH_AGENT agent kind, default "agent" +# VPDASH_AGENT_ID stable id; when unset the server derives one +# VPDASH_AGENT_NAME display name; when unset the server derives one +# VPDASH_HOST host label, default `hostname` +# +# Exits 0 even when the server is unreachable: a dashboard must never take an +# agent down with it. +set -u + +url="${VPDASH_URL:-}" +[ -n "$url" ] || exit 0 + +agent="${VPDASH_AGENT:-agent}" +agent_id="${VPDASH_AGENT_ID:-}" +name="${VPDASH_AGENT_NAME:-}" +host="${VPDASH_HOST:-$(hostname 2>/dev/null || echo unknown)}" +cwd="$PWD" +session="" +state="" +event="" +message="" + +while [ $# -gt 0 ]; do + case "$1" in + --state) state="${2:-}"; shift 2 ;; + --event) event="${2:-}"; shift 2 ;; + --message) message="${2:-}"; shift 2 ;; + --agent) agent="${2:-}"; shift 2 ;; + --id) agent_id="${2:-}"; shift 2 ;; + --name) name="${2:-}"; shift 2 ;; + --session) session="${2:-}"; shift 2 ;; + --cwd) cwd="${2:-}"; shift 2 ;; + --host) host="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "vpdash-report: unknown argument $1" >&2; exit 2 ;; + esac +done + +# JSON string body: escape backslashes and quotes, drop control characters. +esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\000-\037' +} + +# Emit `"key":"value",` only for values that are set. +pair() { + [ -n "$2" ] || return 0 + printf '"%s":"%s",' "$1" "$(esc "$2")" +} + +payload=$( + printf '{' + pair agent "$agent" + pair agent_id "$agent_id" + pair name "$name" + pair state "$state" + pair event "$event" + pair message "$message" + pair session_id "$session" + pair host "$host" + printf '"cwd":"%s"}' "$(esc "$cwd")" +) + +# -S keeps curl's error message even in silent mode, and http_code exposes a +# rejection (401 without a token, say) that curl itself would call success. +# Callers (hooks) route stderr into their trace log. +out=$( + curl -sS -o /dev/null -w 'http_code=%{http_code}' --connect-timeout 1 --max-time 3 \ + -X POST "${url%/}/api/v1/events" \ + -H "Content-Type: application/json" \ + ${VPDASH_TOKEN:+-H "Authorization: Bearer $VPDASH_TOKEN"} \ + -d "$payload" 2>&1 +) || true + +case "$out" in + *http_code=2*) ;; # accepted + *http_code=401* | *http_code=403*) + printf 'vpdash-report: %s (is VPDASH_TOKEN the dash ingest token?)\n' "$out" >&2 + ;; + *) printf 'vpdash-report: %s\n' "$out" >&2 ;; +esac + +exit 0 diff --git a/src/vibepod/resources/herdr/codex/herdr-agent-state.sh b/src/vibepod/resources/herdr/codex/herdr-agent-state.sh index 6fae7b3..afcaecf 100755 --- a/src/vibepod/resources/herdr/codex/herdr-agent-state.sh +++ b/src/vibepod/resources/herdr/codex/herdr-agent-state.sh @@ -1,7 +1,8 @@ #!/bin/sh -# Managed by VibePod โ€” reports Codex notify events to herdr. -# Codex passes a single JSON argument, e.g. {"type":"agent-turn-complete",...}. -# Transport: node + socket API (primary), herdr binary (fallback). Traced to +# Managed by VibePod โ€” reports Codex lifecycle hook events to herdr. +# Receives the hook payload as JSON on stdin; always exits 0 so a broken +# herdr setup never disturbs the agent. Transport: node + socket API +# (primary), herdr binary (fallback). Traced to # $HOME/.codex/herdr-hook.log (host-visible via the config mount). set -u @@ -13,41 +14,66 @@ log() { script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) reporter="$script_dir/herdr-report.js" +payload=$(cat 2>/dev/null || true) -payload="${1:-}" -type=$(printf '%s' "$payload" \ - | tr -d '\n' \ - | sed -n 's/.*"type"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +json_field() { + printf '%s' "$payload" \ + | tr -d '\n' \ + | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" +} + +event=$(json_field hook_event_name) +session_id=$(json_field session_id) +transcript=$(json_field transcript_path) if [ -z "${HERDR_PANE_ID:-}" ]; then - log "skip type=${type:-?} reason=HERDR_PANE_ID unset" + log "skip event=${event:-?} reason=HERDR_PANE_ID unset" exit 0 fi -send_state() { +# send +send() { if command -v node >/dev/null 2>&1 && [ -f "$reporter" ] \ && [ -n "${HERDR_SOCKET_PATH:-}" ]; then - out=$(node "$reporter" pane.report_agent codex "$1" 2>&1) + out=$(node "$reporter" "$1" codex "$2" "$3" "$4" 2>&1) rc=$? via=socket elif [ -n "${HERDR_BIN_PATH:-}" ] && [ -x "$HERDR_BIN_PATH" ]; then - out=$("$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source vibepod --agent codex --state "$1" 2>&1) + if [ "$1" = "pane.report_agent" ]; then + out=$("$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source vibepod --agent codex --state "$2" \ + ${3:+--agent-session-id "$3"} 2>&1) + else + out=$("$HERDR_BIN_PATH" pane report-agent-session "$HERDR_PANE_ID" \ + --source vibepod --agent codex \ + ${3:+--agent-session-id "$3"} \ + ${4:+--agent-session-path "$4"} 2>&1) + fi rc=$? via=binary else - log "skip type=${type:-?} reason=no transport (node+reporter or herdr binary)" + log "skip event=${event:-?} reason=no transport (node+reporter or herdr binary)" return fi - log "report state=$1 type=$type via=$via rc=$rc${out:+ out=$out}" + log "$1 state=${2:-โ€“} event=$event via=$via rc=$rc${out:+ out=$out}" } -case "$type" in - agent-turn-complete) - send_state idle +case "$event" in + SessionStart) + send pane.report_agent_session "" "$session_id" "$transcript" + send pane.report_agent idle "$session_id" "" + ;; + UserPromptSubmit|PreToolUse|PostToolUse) + send pane.report_agent working "$session_id" "" + ;; + PermissionRequest) + send pane.report_agent blocked "$session_id" "" + ;; + Stop|Interrupt|SessionEnd) + send pane.report_agent idle "$session_id" "" ;; *) - log "ignore type=${type:-?}" + log "ignore event=${event:-?}" ;; esac diff --git a/tests/conftest.py b/tests/conftest.py index 224749e..6e42489 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,3 +26,15 @@ def _no_ambient_herdr_env(monkeypatch): for key in list(os.environ): if key.startswith("HERDR_"): monkeypatch.delenv(key, raising=False) + + +@pytest.fixture(autouse=True) +def _no_ambient_dash_env(monkeypatch): + """Strip VPDASH_* env so tests never report to a real dashboard. + + A developer with VPDASH_URL exported (the zero-config way to point `vp run` + at a board) would otherwise have the suite post events to it. + """ + for key in list(os.environ): + if key.startswith("VPDASH_"): + monkeypatch.delenv(key, raising=False) diff --git a/tests/test_codex_hooks.py b/tests/test_codex_hooks.py new file mode 100644 index 0000000..44b2cbb --- /dev/null +++ b/tests/test_codex_hooks.py @@ -0,0 +1,285 @@ +"""Shared Codex lifecycle-hook registration tests.""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + + +def _module() -> ModuleType: + assert importlib.util.find_spec("vibepod.core.codex_hooks") is not None + return importlib.import_module("vibepod.core.codex_hooks") + + +def _commands(path: Path, event: str) -> list[str]: + data = json.loads(path.read_text(encoding="utf-8")) + return [ + hook["command"] + for group in data["hooks"][event] + if isinstance(group, dict) + for hook in group.get("hooks", []) + if isinstance(hook, dict) and hook.get("type") == "command" + ] + + +def test_register_creates_every_lifecycle_event(tmp_path: Path) -> None: + codex_hooks = _module() + command = "/config/.codex/dash-agent-state.sh" + + assert codex_hooks.register(tmp_path, command, label="dash") + + path = tmp_path / ".codex" / "hooks.json" + for event in codex_hooks.LIFECYCLE_EVENTS: + assert _commands(path, event) == [command] + + +def test_register_preserves_user_hooks_and_is_idempotent(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "description": "mine", + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "mine.sh"}]}, + ], + "PreCompact": [ + {"hooks": [{"type": "command", "command": "compact.sh"}]}, + ], + }, + }, + ), + encoding="utf-8", + ) + command = "/config/.codex/herdr-agent-state.sh" + + assert codex_hooks.register(tmp_path, command, label="herdr") + first = path.read_text(encoding="utf-8") + assert codex_hooks.register(tmp_path, command, label="herdr") + + assert path.read_text(encoding="utf-8") == first + data = json.loads(first) + assert data["description"] == "mine" + assert _commands(path, "Stop") == ["mine.sh", command] + assert _commands(path, "PreCompact") == ["compact.sh"] + + +def test_register_preserves_a_matcher_group_without_handlers(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"hooks": {"PreToolUse": [{"matcher": "Bash"}]}}), + encoding="utf-8", + ) + + assert codex_hooks.register(tmp_path, "hook.sh", label="dash") + + data = json.loads(path.read_text(encoding="utf-8")) + assert data["hooks"]["PreToolUse"][0] == {"matcher": "Bash"} + assert _commands(path, "PreToolUse") == ["hook.sh"] + + +@pytest.mark.parametrize("order", [("herdr", "dash"), ("dash", "herdr")]) +def test_dash_and_herdr_handlers_coexist(tmp_path: Path, order: tuple[str, str]) -> None: + codex_hooks = _module() + commands = { + "dash": "/config/.codex/dash-agent-state.sh", + "herdr": "/config/.codex/herdr-agent-state.sh", + } + + for label in order: + assert codex_hooks.register(tmp_path, commands[label], label=label) + + path = tmp_path / ".codex" / "hooks.json" + for event in codex_hooks.LIFECYCLE_EVENTS: + assert _commands(path, event) == [commands[label] for label in order] + + +@pytest.mark.parametrize( + "legacy", + [ + 'notify = ["/config/.codex/herdr-agent-state.sh"]', + 'notify = ["/config/.codex/dash-agent-state.sh"]', + ], +) +def test_register_removes_legacy_vibepod_notify(tmp_path: Path, legacy: str) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text(f'model = "gpt"\n{legacy}\n', encoding="utf-8") + + assert codex_hooks.register( + tmp_path, + "/config/.codex/dash-agent-state.sh", + label="dash", + ) + + content = path.read_text(encoding="utf-8") + assert legacy not in content + assert 'model = "gpt"' in content + + +def test_register_removes_legacy_notify_from_a_toml_section(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text( + "[notice.model_migrations]\n" + "seen = true\n" + 'notify = ["/config/.codex/herdr-agent-state.sh"]\n', + encoding="utf-8", + ) + + assert codex_hooks.register( + tmp_path, + "/config/.codex/herdr-agent-state.sh", + label="herdr", + ) + + assert "notify" not in path.read_text(encoding="utf-8") + + +def test_register_preserves_user_notify(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text('notify = ["my-notifier"]\n', encoding="utf-8") + + assert codex_hooks.register(tmp_path, "hook.sh", label="dash") + + assert path.read_text(encoding="utf-8") == 'notify = ["my-notifier"]\n' + + +def test_register_leaves_malformed_hooks_unchanged(tmp_path: Path, capsys) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + + assert not codex_hooks.register(tmp_path, "hook.sh", label="dash") + + assert path.read_text(encoding="utf-8") == "{broken" + assert "hooks.json" in capsys.readouterr().out + + +def test_register_leaves_an_event_with_a_malformed_shape_unchanged( + tmp_path: Path, + capsys, +) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"hooks": {"Stop": {"bad": "shape"}}}), encoding="utf-8") + original = path.read_text(encoding="utf-8") + + assert not codex_hooks.register(tmp_path, "hook.sh", label="dash") + + assert path.read_text(encoding="utf-8") == original + assert "Stop" in capsys.readouterr().out + + +@pytest.mark.parametrize("nested_hooks", [None, 7, {"command": "hook.sh"}]) +def test_register_soft_fails_on_malformed_nested_hooks( + tmp_path: Path, + capsys, + nested_hooks: object, +) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"hooks": {"Stop": [{"hooks": nested_hooks}]}}), + encoding="utf-8", + ) + original = path.read_text(encoding="utf-8") + + assert not codex_hooks.register(tmp_path, "hook.sh", label="dash") + + assert path.read_text(encoding="utf-8") == original + assert "Stop" in capsys.readouterr().out + + +def test_register_leaves_malformed_toml_unchanged(tmp_path: Path, capsys) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text("model = [unclosed\n", encoding="utf-8") + + assert codex_hooks.register(tmp_path, "hook.sh", label="dash") + + assert path.read_text(encoding="utf-8") == "model = [unclosed\n" + assert "TOML" in capsys.readouterr().out + + +def test_registered_detects_exact_command_and_soft_fails(tmp_path: Path) -> None: + codex_hooks = _module() + assert not codex_hooks.registered(tmp_path, "hook.sh") + assert codex_hooks.register(tmp_path, "hook.sh", label="dash") + assert codex_hooks.registered(tmp_path, "hook.sh") + assert not codex_hooks.registered(tmp_path, "other.sh") + + path = tmp_path / ".codex" / "hooks.json" + path.write_text("{broken", encoding="utf-8") + assert not codex_hooks.registered(tmp_path, "hook.sh") + + +def test_registered_rejects_a_partial_lifecycle_registration(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "hook.sh"}]}, + ], + }, + }, + ), + encoding="utf-8", + ) + + assert not codex_hooks.registered(tmp_path, "hook.sh") + + +def test_registration_status_distinguishes_diagnostic_states(tmp_path: Path) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + + assert codex_hooks.registration_status(tmp_path, "hook.sh") == "missing" + + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + assert codex_hooks.registration_status(tmp_path, "hook.sh") == "malformed" + + path.write_text(json.dumps({"hooks": {}}), encoding="utf-8") + assert codex_hooks.registration_status(tmp_path, "hook.sh") == "handler-absent" + + assert codex_hooks.register(tmp_path, "hook.sh", label="dash") + assert codex_hooks.registration_status(tmp_path, "hook.sh") == "registered" + + +@pytest.mark.parametrize("nested_hooks", [None, 7, {"command": "hook.sh"}]) +def test_registration_status_soft_fails_on_malformed_nested_hooks( + tmp_path: Path, + nested_hooks: object, +) -> None: + codex_hooks = _module() + path = tmp_path / ".codex" / "hooks.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"hooks": {"Stop": [{"hooks": nested_hooks}]}}), + encoding="utf-8", + ) + + assert codex_hooks.registration_status(tmp_path, "hook.sh") == "malformed" + assert not codex_hooks.registered(tmp_path, "hook.sh") diff --git a/tests/test_dash.py b/tests/test_dash.py new file mode 100644 index 0000000..34a1d01 --- /dev/null +++ b/tests/test_dash.py @@ -0,0 +1,841 @@ +"""Tests for the VibePod Dash integration (core/dash.py).""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import threading +import urllib.error +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from vibepod.core import dash + +DASH_CONFIG: dict[str, Any] = {"dash": {"url": "http://dash.local:8765", "token": "t0ken"}} + + +class _Recorder(BaseHTTPRequestHandler): + """Collects every POST body in ``server.received``; answers /healthz.""" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self.path != "/healthz": + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok": true}') + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length).decode("utf-8") + self.server.received.append((json.loads(body), dict(self.headers))) # type: ignore[attr-defined] + self.send_response(202) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok": true}') + + def log_message(self, *args: Any) -> None: + """Silence the default stderr access log.""" + + +@pytest.fixture +def dash_server() -> Iterator[Any]: + server = ThreadingHTTPServer(("127.0.0.1", 0), _Recorder) + server.received = [] # type: ignore[attr-defined] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def server_url(server: Any) -> str: + return f"http://127.0.0.1:{server.server_address[1]}" + + +@pytest.fixture(autouse=True) +def _clean_dash_state() -> Iterator[None]: + """The warn-once and host-URL caches are process-global.""" + dash.reset_state() + yield + dash.reset_state() + + +# -- configuration -------------------------------------------------------- + + +def test_no_dashboard_configured_is_a_no_op(tmp_path: Path) -> None: + target, env = dash.apply_dash_if_enabled( + "claude", + tmp_path, + tmp_path, + {}, + config_mount_path="/claude", + no_dash=False, + ) + assert target is None + assert env == {} + + +def test_url_and_token_come_from_config() -> None: + assert dash.resolve_url(DASH_CONFIG) == "http://dash.local:8765" + assert dash.resolve_token(DASH_CONFIG) == "t0ken" + + +def test_env_overrides_config(monkeypatch) -> None: + monkeypatch.setenv("VPDASH_URL", "http://other:9000/") + monkeypatch.setenv("VPDASH_TOKEN", "env-token") + assert dash.resolve_url(DASH_CONFIG) == "http://other:9000" + assert dash.resolve_token(DASH_CONFIG) == "env-token" + + +def test_bare_host_gets_a_scheme() -> None: + assert dash.resolve_url({"dash": {"url": "dash.local:8765"}}) == "http://dash.local:8765" + + +def test_disabled_by_config() -> None: + assert dash.dash_enabled({}) is True + assert dash.dash_enabled({"dash": False}) is False + assert dash.dash_enabled({"dash": {"enabled": False, "url": "http://x"}}) is False + + +def test_no_dash_flag_wins(tmp_path: Path) -> None: + target, env = dash.apply_dash_if_enabled( + "claude", + tmp_path, + tmp_path, + DASH_CONFIG, + config_mount_path="/claude", + no_dash=True, + ) + assert target is None + assert env == {} + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("http://localhost:8765", "http://host.docker.internal:8765"), + ("http://127.0.0.1:8765", "http://host.docker.internal:8765"), + ("http://localhost", "http://host.docker.internal"), + ("https://dash.example.com", "https://dash.example.com"), + ("http://192.168.1.5:8765", "http://192.168.1.5:8765"), + ], +) +def test_loopback_urls_are_rewritten_for_the_container(configured: str, expected: str) -> None: + assert dash.container_url(configured) == expected + + +def test_container_url_can_be_pinned_for_a_shared_network() -> None: + """The dash container on vibepod-network is reachable by name โ€” but only + from other containers, so the CLI keeps posting to the host URL.""" + config = {"dash": {"url": "http://localhost:8765", "container_url": "http://vibepod-dash:8765"}} + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + assert target.host_url == "http://localhost:8765" + assert target.container_url == "http://vibepod-dash:8765" + assert dash.container_env(target, "/claude")["VPDASH_URL"] == "http://vibepod-dash:8765" + + +def test_container_url_override_from_the_environment(monkeypatch) -> None: + monkeypatch.setenv("VPDASH_CONTAINER_URL", "vibepod-dash:8765") + assert dash.resolve_container_url({}, "http://localhost:8765") == "http://vibepod-dash:8765" + + +def test_container_url_defaults_to_the_gateway_rewrite() -> None: + assert ( + dash.resolve_container_url({}, "http://localhost:8765") + == "http://host.docker.internal:8765" + ) + + +def test_a_container_only_url_falls_back_to_the_published_port( + dash_server: Any, + capsys, +) -> None: + """`dash.url: http://vibepod-dash:8765` is what people naturally configure + once the board is on the VibePod network; the CLI must still be able to + report from the host.""" + port = dash_server.server_address[1] + config = {"dash": {"url": f"http://vibepod-dash.invalid:{port}"}} + + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + # Agents keep the name they can resolve... + assert target.container_url == f"http://vibepod-dash.invalid:{port}" + # ...the CLI switches to the loopback address that answered. + assert target.host_url == f"http://127.0.0.1:{port}" + assert "container-only" in capsys.readouterr().out + + assert dash.report(target, "idle") is True + assert dash_server.received[0][0]["state"] == "idle" + + +def test_the_fallback_is_only_used_when_something_answers(capsys) -> None: + # Nothing is listening on port 1, so the configured URL is kept and the + # failure is reported honestly rather than papered over. + url = "http://vibepod-dash.invalid:1" + assert dash.usable_host_url(url) == url + assert "container-only" not in capsys.readouterr().out + + +def test_a_resolvable_url_is_never_probed(monkeypatch) -> None: + def fail(*args: Any, **kwargs: Any) -> bool: + raise AssertionError("should not probe a URL whose host resolves") + + monkeypatch.setattr(dash, "_answers", fail) + assert dash.usable_host_url("http://localhost:8765") == "http://localhost:8765" + + +def test_report_failures_are_only_warned_about_once(capsys) -> None: + target = dash.make_target("claude", Path("/work/proj"), {"dash": {"url": "http://127.0.0.1:1"}}) + assert target is not None + dash.report(target, "idle") + first = capsys.readouterr().out + dash.report(target, "done") + assert first != "" + assert capsys.readouterr().out == "" + + +def test_name_resolution_errors_are_recognized() -> None: + gai = socket.gaierror(-3, "Temporary failure in name resolution") + assert dash.is_name_resolution_error(gai) is True + assert dash.is_name_resolution_error(urllib.error.URLError(gai)) is True + assert dash.is_name_resolution_error(urllib.error.URLError(ConnectionRefusedError())) is False + + +def test_an_unresolvable_host_explains_itself(capsys) -> None: + """What `vp run` printed before this hint existed was just errno -3.""" + failure = urllib.error.URLError(socket.gaierror(-3, "Temporary failure in name resolution")) + dash._warn_once("http://vibepod-dash:8765", "idle", failure) + + out = capsys.readouterr().out + assert "vibepod-dash" in out + assert "does not resolve" in out + assert "dash.container_url" in out + + +def test_agent_id_is_stable_per_workspace() -> None: + first = dash.agent_id("claude", Path("/work/proj"), "box") + assert first == dash.agent_id("claude", Path("/work/proj"), "box") + assert first != dash.agent_id("claude", Path("/work/other"), "box") + assert first != dash.agent_id("codex", Path("/work/proj"), "box") + assert first != dash.agent_id("claude", Path("/work/proj"), "laptop") + + +def test_agent_identity_can_be_overridden(monkeypatch) -> None: + monkeypatch.setenv("VPDASH_AGENT_ID", "mine") + monkeypatch.setenv("VPDASH_AGENT_NAME", "my agent") + target = dash.make_target("claude", Path("/work/proj"), DASH_CONFIG) + assert target is not None + assert (target.agent_id, target.name) == ("mine", "my agent") + + +def test_display_name_marks_vibepod_runs() -> None: + assert dash.display_name("claude", Path("/work/vibepod-cli")) == "vp:claude ยท vibepod-cli" + + +def test_container_env_carries_identity_and_log(monkeypatch) -> None: + monkeypatch.setenv("VPDASH_HOST", "box") + target = dash.make_target("claude", Path("/work/proj"), DASH_CONFIG) + assert target is not None + env = dash.container_env(target, "/claude") + assert env["VPDASH_URL"] == "http://dash.local:8765" + assert env["VPDASH_AGENT"] == "claude" + assert env["VPDASH_AGENT_NAME"] == "vp:claude ยท proj" + assert env["VPDASH_HOST"] == "box" + assert env["VPDASH_LOG"] == "/claude/dash-hook.log" + assert env["VPDASH_TOKEN"] == "t0ken" + + +def test_container_env_omits_an_unset_token() -> None: + target = dash.make_target("claude", Path("/work/proj"), {"dash": {"url": "http://d:1"}}) + assert target is not None + assert "VPDASH_TOKEN" not in dash.container_env(target, "/claude") + + +# -- file sync ------------------------------------------------------------ + + +@pytest.mark.parametrize("agent", sorted(dash.BUILTIN_INTEGRATIONS)) +def test_builtin_files_are_copied_and_executable(agent: str, tmp_path: Path) -> None: + synced = dash.sync_dash_files(agent, tmp_path, {}) + assert synced == len(dash.BUILTIN_INTEGRATIONS[agent]) + for _, dest_rel in dash.BUILTIN_INTEGRATIONS[agent]: + dest = tmp_path / dest_rel + assert dest.is_file() + assert os.access(dest, os.X_OK) + # The hook always sits next to the reporter it calls. + hooks = {(tmp_path / dest).parent for _, dest in dash.BUILTIN_INTEGRATIONS[agent]} + assert len(hooks) == 1 + + +def test_packaged_resources_exist_for_every_integration() -> None: + root = dash.resource_root() + for entries in dash.BUILTIN_INTEGRATIONS.values(): + for resource_rel, _ in entries: + assert (root / resource_rel).is_file(), resource_rel + + +def test_codex_uses_vibepod_owned_lifecycle_adapter() -> None: + assert dash.BUILTIN_INTEGRATIONS["codex"] == [ + ("dash/vpdash-report.sh", ".codex/vpdash-report.sh"), + ("codex/dash-agent-state.sh", ".codex/dash-agent-state.sh"), + ] + + +def test_custom_integration_entries_are_copied(tmp_path: Path) -> None: + source = tmp_path / "mine.sh" + source.write_text("#!/bin/sh\n", encoding="utf-8") + config_dir = tmp_path / "cfg" + config_dir.mkdir() + config = {"dash": {"integrations": {"gemini": [{"source": str(source), "dest": "h/mine.sh"}]}}} + + assert dash.sync_dash_files("gemini", config_dir, config) == 1 + assert (config_dir / "h" / "mine.sh").is_file() + + +def test_integration_dest_cannot_escape_the_config_dir(tmp_path: Path) -> None: + source = tmp_path / "mine.sh" + source.write_text("#!/bin/sh\n", encoding="utf-8") + config_dir = tmp_path / "cfg" + config_dir.mkdir() + config = { + "dash": {"integrations": {"gemini": [{"source": str(source), "dest": "../escaped.sh"}]}}, + } + + assert dash.sync_dash_files("gemini", config_dir, config) == 0 + assert not (tmp_path / "escaped.sh").exists() + + +# -- hook registration ---------------------------------------------------- + + +def test_claude_hooks_registered_once(tmp_path: Path) -> None: + dash.register_claude_hooks(tmp_path) + dash.register_claude_hooks(tmp_path) + + settings = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + for event in dash._CLAUDE_EVENTS: + commands = [ + hook["command"] for entry in settings["hooks"][event] for hook in entry["hooks"] + ] + assert commands == ['"$CLAUDE_CONFIG_DIR"/hooks/dash-agent-state.sh'] + + +def test_claude_hooks_keep_existing_entries(tmp_path: Path) -> None: + existing = { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": "mine.sh"}]}], + }, + "env": {"FOO": "bar"}, + } + (tmp_path / "settings.json").write_text(json.dumps(existing), encoding="utf-8") + + dash.register_claude_hooks(tmp_path) + + settings = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert settings["env"] == {"FOO": "bar"} + stop_commands = [ + hook["command"] for entry in settings["hooks"]["Stop"] for hook in entry["hooks"] + ] + assert stop_commands[0] == "mine.sh" + assert any("dash-agent-state.sh" in command for command in stop_commands) + + +def test_claude_hooks_survive_unparsable_settings(tmp_path: Path) -> None: + (tmp_path / "settings.json").write_text("{not json", encoding="utf-8") + dash.register_claude_hooks(tmp_path) + assert (tmp_path / "settings.json").read_text(encoding="utf-8") == "{not json" + + +def test_codex_lifecycle_hooks_are_registered_once(tmp_path: Path) -> None: + dash.register_codex_hooks(tmp_path) + dash.register_codex_hooks(tmp_path) + + data = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + for groups in data["hooks"].values(): + commands = [hook["command"] for group in groups for hook in group["hooks"]] + assert commands == [dash.CODEX_HOOK_COMMAND] + + +def test_codex_lifecycle_hooks_coexist_with_herdr(tmp_path: Path) -> None: + hooks_path = tmp_path / ".codex" / "hooks.json" + hooks_path.parent.mkdir(parents=True) + hooks_path.write_text( + json.dumps( + { + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/config/.codex/herdr-agent-state.sh", + }, + ], + }, + ], + }, + }, + ), + encoding="utf-8", + ) + + dash.register_codex_hooks(tmp_path) + + data = json.loads(hooks_path.read_text(encoding="utf-8")) + commands = [hook["command"] for group in data["hooks"]["Stop"] for hook in group["hooks"]] + assert commands == ["/config/.codex/herdr-agent-state.sh", dash.CODEX_HOOK_COMMAND] + + +# -- reporting ------------------------------------------------------------ + + +def test_report_posts_the_agent_state(dash_server: Any, monkeypatch) -> None: + monkeypatch.setenv("VPDASH_HOST", "box") + config = {"dash": {"url": server_url(dash_server), "token": "t0ken"}} + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + + assert dash.report(target, "working", event="run", message="hi", cwd="/work/proj") is True + + payload, headers = dash_server.received[0] + assert payload["state"] == "working" + assert payload["agent"] == "claude" + assert payload["agent_id"] == target.agent_id + assert payload["name"] == "vp:claude ยท proj" + assert payload["message"] == "hi" + assert payload["host"] == "box" + assert headers["Authorization"] == "Bearer t0ken" + + +def test_report_survives_an_unreachable_dashboard(capsys) -> None: + # Port 1 refuses connections everywhere; nothing must be raised. + target = dash.make_target("claude", Path("/work/proj"), {"dash": {"url": "http://127.0.0.1:1"}}) + assert target is not None + assert dash.report(target, "done") is False + assert "dash" in capsys.readouterr().out.lower() + + +def test_report_can_stay_quiet(capsys) -> None: + target = dash.make_target("claude", Path("/work/proj"), {"dash": {"url": "http://127.0.0.1:1"}}) + assert target is not None + assert dash.report(target, "done", quiet=True) is False + assert capsys.readouterr().out == "" + + +def test_apply_wires_env_and_hooks(dash_server: Any, tmp_path: Path) -> None: + config = {"dash": {"url": server_url(dash_server)}} + target, env = dash.apply_dash_if_enabled( + "claude", + tmp_path, + Path("/work/proj"), + config, + config_mount_path="/claude", + no_dash=False, + ) + assert target is not None + # The CLI keeps the loopback URL; the container gets the gateway one. + assert target.host_url == server_url(dash_server) + port = dash_server.server_address[1] + assert env["VPDASH_URL"] == f"http://host.docker.internal:{port}" + assert (tmp_path / "hooks" / "dash-agent-state.sh").is_file() + assert (tmp_path / "hooks" / "vpdash-report.sh").is_file() + settings = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert len(settings["hooks"]) == len(dash._CLAUDE_EVENTS) + + +def test_target_from_labels_round_trip(dash_server: Any) -> None: + config = {"dash": {"url": server_url(dash_server)}} + labels = {dash.AGENT_LABEL: "codex", dash.AGENT_ID_LABEL: "abc123"} + + target = dash.target_from_labels(labels, config) + assert target is not None + assert (target.agent, target.agent_id) == ("codex", "abc123") + # No name: a stop report must not rename the card on the board. + assert target.name == "" + + +def test_target_from_labels_needs_both_labels() -> None: + assert dash.target_from_labels({dash.AGENT_LABEL: "codex"}, DASH_CONFIG) is None + assert dash.target_from_labels({}, DASH_CONFIG) is None + + +def test_stop_reports_done_from_container_labels(dash_server: Any, monkeypatch) -> None: + from vibepod.commands import stop as stop_module + + monkeypatch.setattr( + stop_module, + "get_config", + lambda: {"dash": {"url": server_url(dash_server)}}, + ) + + class FakeContainer: + labels = { + "vibepod.agent": "codex", + dash.AGENT_LABEL: "codex", + dash.AGENT_ID_LABEL: "abc123", + } + + stop_module._release_agent_entries([FakeContainer()]) + + payload, _ = dash_server.received[0] + assert payload["state"] == "done" + assert payload["agent_id"] == "abc123" + assert "name" not in payload + + +def test_stop_ignores_containers_without_dash_labels(dash_server: Any, monkeypatch) -> None: + from vibepod.commands import stop as stop_module + + monkeypatch.setattr( + stop_module, + "get_config", + lambda: {"dash": {"url": server_url(dash_server)}}, + ) + + class FakeContainer: + labels = {"vibepod.agent": "codex"} + + stop_module._release_agent_entries([FakeContainer()]) + assert dash_server.received == [] + + +@pytest.mark.skipif( + shutil.which("curl") is None or os.name == "nt", + reason="the vendored hooks need POSIX sh and curl", +) +def test_vendored_claude_hook_reports_over_http(dash_server: Any, tmp_path: Path) -> None: + """Run the injected hook exactly as the container would, minus Docker.""" + config = {"dash": {"url": server_url(dash_server), "token": "t0ken"}} + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + dash.sync_dash_files("claude", tmp_path, {}) + + env = { + **os.environ, + # The config dir stands in for the container's config mount, so + # VPDASH_LOG lands next to the hooks the same way it does in a run. + **dash.container_env(target, str(tmp_path)), + # The hook runs on this host, so it needs the host-side URL. + "VPDASH_URL": server_url(dash_server), + } + payload = json.dumps( + { + "hook_event_name": "Notification", + "session_id": "s1", + "cwd": "/work/proj", + "message": "Claude needs your permission to run Bash", + }, + ) + subprocess.run( + [str(tmp_path / "hooks" / "dash-agent-state.sh")], + input=payload, + text=True, + env=env, + check=True, + timeout=30, + ) + + body, headers = dash_server.received[0] + assert body["state"] == "blocked" + assert body["message"] == "Claude needs your permission to run Bash" + assert body["agent"] == "claude" + assert body["agent_id"] == target.agent_id + assert body["name"] == "vp:claude ยท proj" + assert headers["Authorization"] == "Bearer t0ken" + # The trace log `vp doctor dash` reads back. + assert "state=blocked" in (tmp_path / "dash-hook.log").read_text(encoding="utf-8") + + +@pytest.mark.skipif( + shutil.which("curl") is None or os.name == "nt", + reason="the lifecycle adapter needs POSIX sh and curl", +) +@pytest.mark.parametrize( + ("event", "state", "message"), + [ + ("SessionStart", "idle", "session started"), + ("UserPromptSubmit", "working", "fix it"), + ("PreToolUse", "working", "Bash"), + ("PostToolUse", "working", "Bash"), + ("PermissionRequest", "blocked", "run the build"), + ("Stop", "idle", "finished this turn"), + ("Interrupt", "idle", "turn interrupted"), + ("SessionEnd", "done", "session ended"), + ], +) +def test_codex_lifecycle_hook_reports_over_http( + event: str, + state: str, + message: str, + dash_server: Any, + tmp_path: Path, +) -> None: + config = {"dash": {"url": server_url(dash_server), "token": "t0ken"}} + target = dash.make_target("codex", Path("/work/proj"), config) + assert target is not None + dash.sync_dash_files("codex", tmp_path, {}) + payload = json.dumps( + { + "hook_event_name": event, + "session_id": "s1", + "cwd": "/work/proj", + "prompt": "fix it", + "tool_name": "Bash", + "tool_input": {"description": "run the build"}, + "last_assistant_message": "finished this turn", + }, + ) + + proc = subprocess.run( + [str(tmp_path / ".codex" / "dash-agent-state.sh")], + input=payload, + capture_output=True, + text=True, + env={ + **os.environ, + **dash.container_env(target, str(tmp_path)), + "VPDASH_URL": server_url(dash_server), + }, + check=True, + timeout=30, + ) + + assert proc.stdout == "" + body, headers = dash_server.received[0] + assert body["state"] == state + assert body["event"] == event + assert body["message"] == message + assert body["session_id"] == "s1" + assert headers["Authorization"] == "Bearer t0ken" + + +@pytest.mark.skipif(os.name == "nt", reason="the lifecycle adapter needs POSIX sh") +@pytest.mark.parametrize( + ("event", "state", "message"), + [ + ("SessionStart", "idle", "session started"), + ("UserPromptSubmit", "working", "fix it"), + ("PreToolUse", "working", "Bash"), + ("PostToolUse", "working", "Bash"), + ("PermissionRequest", "blocked", "run the build"), + ("Stop", "idle", "finished this turn"), + ("Interrupt", "idle", "turn interrupted"), + ("SessionEnd", "done", "session ended"), + ], +) +def test_codex_lifecycle_adapter_maps_reporter_arguments( + event: str, + state: str, + message: str, + tmp_path: Path, +) -> None: + dash.sync_dash_files("codex", tmp_path, {}) + capture = tmp_path / "args.txt" + reporter = tmp_path / ".codex" / "vpdash-report.sh" + reporter.write_text( + '#!/bin/sh\nfor arg do printf "%s\\n" "$arg"; done >"$CAPTURE"\n', + encoding="utf-8", + ) + reporter.chmod(0o755) + payload = json.dumps( + { + "hook_event_name": event, + "session_id": "s1", + "cwd": "/work/proj", + "prompt": "fix it", + "tool_name": "Bash", + "tool_input": {"description": "run the build"}, + "last_assistant_message": "finished this turn", + }, + ) + + proc = subprocess.run( + ["sh", str(tmp_path / ".codex" / "dash-agent-state.sh")], + input=payload, + capture_output=True, + text=True, + env={ + "PATH": os.environ["PATH"], + "CAPTURE": str(capture), + "VPDASH_URL": "http://dash.invalid", + }, + check=True, + timeout=15, + ) + + args = capture.read_text(encoding="utf-8").splitlines() + values = dict(zip(args[::2], args[1::2], strict=True)) + assert proc.stdout == "" + assert values["--state"] == state + assert values["--event"] == event + assert values["--message"] == message + assert values["--session"] == "s1" + assert values["--cwd"] == "/work/proj" + + +# -- doctor --------------------------------------------------------------- + + +def test_doctor_detects_complete_codex_lifecycle_registration(tmp_path: Path) -> None: + from vibepod.commands.doctor import _dash_registration + + hooks_path = tmp_path / ".codex" / "hooks.json" + assert _dash_registration("codex", tmp_path) == "MISSING hooks.json" + + hooks_path.parent.mkdir(parents=True) + hooks_path.write_text("{broken", encoding="utf-8") + assert _dash_registration("codex", tmp_path) == "INVALID hooks.json" + + hooks_path.write_text(json.dumps({"hooks": {}}), encoding="utf-8") + assert _dash_registration("codex", tmp_path) == "MISSING handler" + + dash.register_codex_hooks(tmp_path) + assert _dash_registration("codex", tmp_path) == "hooks.json" + + +def test_doctor_dash_needs_a_url(monkeypatch, tmp_path: Path) -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + result = CliRunner().invoke(app, ["doctor", "dash"]) + assert result.exit_code == 1 + assert "no dashboard URL" in result.output + + +def test_doctor_dash_summarizes_every_agent(monkeypatch, tmp_path: Path, dash_server) -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + from vibepod.constants import SUPPORTED_AGENTS + + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("VPDASH_URL", server_url(dash_server)) + + result = CliRunner().invoke(app, ["doctor", "dash"]) + output = result.output + # The reachability probe hits /healthz, which the recorder does not serve; + # the summary must still be printed. + for agent in SUPPORTED_AGENTS: + assert agent in output + assert "dash integration per agent" in output + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("missing", "MISSING hooks.json"), + ("malformed", "INVALID hooks.json"), + ("handler-absent", "MISSING handler"), + ("registered", "hooks.json"), + ], +) +def test_doctor_dash_summary_distinguishes_codex_hook_states( + status: str, + expected: str, + monkeypatch, + tmp_path: Path, + dash_server, +) -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + + config_dir = tmp_path / "agents" / "codex" + hooks_path = config_dir / ".codex" / "hooks.json" + if status != "missing": + hooks_path.parent.mkdir(parents=True) + content = "{broken" if status == "malformed" else json.dumps({"hooks": {}}) + hooks_path.write_text(content, encoding="utf-8") + if status == "registered": + dash.register_codex_hooks(config_dir) + + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("VPDASH_URL", server_url(dash_server)) + + result = CliRunner().invoke(app, ["doctor", "dash"]) + assert result.exit_code == 0 + assert expected in result.output + + +def test_run_and_task_expose_the_opt_out() -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + + runner = CliRunner() + assert "--no-dash" in runner.invoke(app, ["run", "--help"]).output + assert "--no-dash" in runner.invoke(app, ["task", "create", "--help"]).output + + +def test_a_rejected_token_says_where_to_find_the_right_one(capsys) -> None: + rejection = urllib.error.HTTPError("http://d:8765", 401, "Unauthorized", {}, None) # type: ignore[arg-type] + dash._warn_once("http://d:8765", "idle", rejection) + + out = capsys.readouterr().out + assert "rejected the token" in out + assert "VPDASH_TOKEN" in out + assert "ingest token" in out + + +def test_details_drops_unset_fields_and_stringifies() -> None: + built = dash.details( + workspace=Path("/work/proj"), + image="vibepod/claude:latest", + profile=None, + container="", + task=42, + ) + assert built == { + "workspace": "/work/proj", + "image": "vibepod/claude:latest", + "task": "42", + } + + +def test_report_carries_the_run_details(dash_server: Any) -> None: + config = {"dash": {"url": server_url(dash_server)}} + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + + assert dash.report( + target, + "working", + cwd="/work/proj", + data=dash.details( + workspace=Path("/work/proj"), + image="vibepod/claude:latest", + profile="work", + container="vibepod-claude-ab12cd34", + vibepod="0.21.0", + ), + ) + + payload, _ = dash_server.received[0] + assert payload["data"]["image"] == "vibepod/claude:latest" + assert payload["data"]["profile"] == "work" + assert payload["data"]["container"] == "vibepod-claude-ab12cd34" + assert payload["data"]["workspace"] == "/work/proj" + + +def test_report_omits_an_empty_data_block(dash_server: Any) -> None: + config = {"dash": {"url": server_url(dash_server)}} + target = dash.make_target("claude", Path("/work/proj"), config) + assert target is not None + assert dash.report(target, "idle", data=dash.details(profile=None)) + assert "data" not in dash_server.received[0][0] diff --git a/tests/test_herdr.py b/tests/test_herdr.py index 9f23eab..dca9874 100644 --- a/tests/test_herdr.py +++ b/tests/test_herdr.py @@ -8,6 +8,7 @@ import os import shutil import socket as socket_module +import subprocess import sys import tempfile from collections.abc import Iterator @@ -286,37 +287,43 @@ def test_claude_settings_unparseable_left_alone(tmp_path: Path, capsys) -> None: assert "settings.json" in capsys.readouterr().out -def test_codex_notify_registered(tmp_path: Path) -> None: - herdr.register_codex_notify(tmp_path) - content = (tmp_path / ".codex" / "config.toml").read_text() - assert 'notify = ["/config/.codex/herdr-agent-state.sh"]' in content +def test_codex_lifecycle_hooks_registered(tmp_path: Path) -> None: + herdr.register_codex_hooks(tmp_path) + data = json.loads((tmp_path / ".codex" / "hooks.json").read_text()) + for groups in data["hooks"].values(): + commands = [hook["command"] for group in groups for hook in group["hooks"]] + assert herdr.CODEX_HOOK_COMMAND in commands -def test_codex_notify_appends_to_existing_config(tmp_path: Path) -> None: +def test_codex_lifecycle_hooks_preserve_existing_hooks(tmp_path: Path) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) - (codex_dir / "config.toml").write_text('model = "o4"\n') - herdr.register_codex_notify(tmp_path) - content = (codex_dir / "config.toml").read_text() - assert 'model = "o4"' in content - assert "herdr-agent-state.sh" in content + (codex_dir / "hooks.json").write_text( + json.dumps( + {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "mine.sh"}]}]}}, + ), + ) + herdr.register_codex_hooks(tmp_path) + data = json.loads((codex_dir / "hooks.json").read_text()) + commands = [hook["command"] for group in data["hooks"]["Stop"] for hook in group["hooks"]] + assert commands == ["mine.sh", herdr.CODEX_HOOK_COMMAND] -def test_codex_notify_respects_existing_notify(tmp_path: Path, capsys) -> None: +def test_codex_lifecycle_hooks_respect_user_notify(tmp_path: Path) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) (codex_dir / "config.toml").write_text('notify = ["my-notifier"]\n') - herdr.register_codex_notify(tmp_path) + herdr.register_codex_hooks(tmp_path) assert "my-notifier" in (codex_dir / "config.toml").read_text() - assert "herdr-agent-state.sh" not in (codex_dir / "config.toml").read_text() - assert "notify" in capsys.readouterr().out + assert (codex_dir / "hooks.json").is_file() -def test_codex_notify_idempotent(tmp_path: Path) -> None: - herdr.register_codex_notify(tmp_path) - first = (tmp_path / ".codex" / "config.toml").read_text() - herdr.register_codex_notify(tmp_path) - assert (tmp_path / ".codex" / "config.toml").read_text() == first +def test_codex_lifecycle_hooks_are_idempotent(tmp_path: Path) -> None: + herdr.register_codex_hooks(tmp_path) + path = tmp_path / ".codex" / "hooks.json" + first = path.read_text() + herdr.register_codex_hooks(tmp_path) + assert path.read_text() == first def _activate(monkeypatch, tmp_path: Path, with_binary: bool = True) -> None: @@ -368,12 +375,12 @@ def test_apply_wires_claude(monkeypatch, tmp_path: Path) -> None: assert (config_dir / "settings.json").is_file() -def test_apply_wires_codex_notify(monkeypatch, tmp_path: Path) -> None: +def test_apply_wires_codex_lifecycle_hooks(monkeypatch, tmp_path: Path) -> None: _activate(monkeypatch, tmp_path) config_dir = tmp_path / "cfg" config_dir.mkdir() herdr.apply_herdr_if_enabled("codex", config_dir, {}, no_herdr=False) - assert (config_dir / ".codex" / "config.toml").is_file() + assert (config_dir / ".codex" / "hooks.json").is_file() def test_apply_warns_without_binary(monkeypatch, tmp_path: Path, capsys) -> None: @@ -441,48 +448,48 @@ def test_apply_logs_detection_without_integrations(monkeypatch, tmp_path: Path, assert "herdr pane detected" in capsys.readouterr().out -def test_codex_notify_goes_to_root_table_before_sections(tmp_path: Path) -> None: +def test_codex_lifecycle_hooks_remove_legacy_notify(tmp_path: Path) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) (codex_dir / "config.toml").write_text( + 'notify = ["/config/.codex/herdr-agent-state.sh"]\n' 'model = "o4"\n\n[notice.model_migrations]\nseen = true\n', ) - herdr.register_codex_notify(tmp_path) - parsed = herdr.tomllib.loads((codex_dir / "config.toml").read_text()) - assert parsed["notify"] == ["/config/.codex/herdr-agent-state.sh"] - assert "notify" not in parsed["notice"]["model_migrations"] + herdr.register_codex_hooks(tmp_path) + content = (codex_dir / "config.toml").read_text() + assert "notify" not in content + assert 'model = "o4"' in content -def test_codex_notify_repairs_line_misplaced_inside_section(tmp_path: Path) -> None: +def test_codex_lifecycle_hooks_remove_legacy_notify_inside_section(tmp_path: Path) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) (codex_dir / "config.toml").write_text( "[notice.model_migrations]\nseen = true\n" 'notify = ["/config/.codex/herdr-agent-state.sh"]\n', ) - herdr.register_codex_notify(tmp_path) - parsed = herdr.tomllib.loads((codex_dir / "config.toml").read_text()) - assert parsed["notify"] == ["/config/.codex/herdr-agent-state.sh"] - assert "notify" not in parsed["notice"]["model_migrations"] + herdr.register_codex_hooks(tmp_path) + assert "notify" not in (codex_dir / "config.toml").read_text() -def test_codex_notify_skips_invalid_toml(tmp_path: Path, capsys) -> None: +def test_codex_lifecycle_hooks_leave_invalid_toml(tmp_path: Path, capsys) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) (codex_dir / "config.toml").write_text("model = [unclosed\n") - herdr.register_codex_notify(tmp_path) + herdr.register_codex_hooks(tmp_path) assert (codex_dir / "config.toml").read_text() == "model = [unclosed\n" + assert (codex_dir / "hooks.json").is_file() assert "TOML" in capsys.readouterr().out -def test_codex_notify_respects_user_notify_in_root(tmp_path: Path, capsys) -> None: +def test_codex_lifecycle_hooks_preserve_user_notify_in_root(tmp_path: Path) -> None: codex_dir = tmp_path / ".codex" codex_dir.mkdir(parents=True) (codex_dir / "config.toml").write_text('notify = ["my-notifier"]\n\n[other]\nx = 1\n') - herdr.register_codex_notify(tmp_path) + herdr.register_codex_hooks(tmp_path) content = (codex_dir / "config.toml").read_text() assert "my-notifier" in content - assert "herdr-agent-state.sh" not in content + assert (codex_dir / "hooks.json").is_file() def test_doctor_herdr_command_registered() -> None: @@ -495,6 +502,58 @@ def test_doctor_herdr_command_registered() -> None: assert "herdr" in result.output +def test_doctor_detects_complete_codex_lifecycle_registration(tmp_path: Path) -> None: + from vibepod.commands.doctor import _herdr_registration + + hooks_path = tmp_path / ".codex" / "hooks.json" + assert _herdr_registration("codex", tmp_path) == "MISSING hooks.json" + + hooks_path.parent.mkdir(parents=True) + hooks_path.write_text("{broken", encoding="utf-8") + assert _herdr_registration("codex", tmp_path) == "INVALID hooks.json" + + hooks_path.write_text(json.dumps({"hooks": {}}), encoding="utf-8") + assert _herdr_registration("codex", tmp_path) == "MISSING handler" + + herdr.register_codex_hooks(tmp_path) + assert _herdr_registration("codex", tmp_path) == "hooks.json" + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("missing", "MISSING hooks.json"), + ("malformed", "INVALID hooks.json"), + ("handler-absent", "MISSING handler"), + ], +) +def test_doctor_herdr_codex_deep_dive_distinguishes_hook_failures( + status: str, + expected: str, + monkeypatch, + tmp_path: Path, +) -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + + config_dir = tmp_path / "agents" / "codex" + hooks_path = config_dir / ".codex" / "hooks.json" + if status != "missing": + hooks_path.parent.mkdir(parents=True) + content = "{broken" if status == "malformed" else json.dumps({"hooks": {}}) + hooks_path.write_text(content, encoding="utf-8") + + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + for key in ("HERDR_ENV", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HERDR_SOCKET_PATH", str(tmp_path / "missing.sock")) + + result = CliRunner().invoke(app, ["doctor", "herdr", "codex"]) + assert result.exit_code == 1 + assert expected in result.output + + def test_doctor_herdr_reports_missing_pane(monkeypatch) -> None: from typer.testing import CliRunner @@ -611,6 +670,95 @@ def _serve_one(sock_path: Path, received: list) -> object: return _serve_requests(sock_path, received, 1) +@pytest.mark.parametrize( + ("event", "state"), + [ + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("Interrupt", "idle"), + ("SessionEnd", "idle"), + ], +) +def test_codex_lifecycle_hook_reports_state( + event: str, + state: str, + sock_dir: Path, + tmp_path: Path, +) -> None: + if shutil.which("node") is None: + pytest.skip("node not available") + received: list[dict] = [] + sock_path = sock_dir / "herdr.sock" + thread = _serve_one(sock_path, received) + config_dir = tmp_path / "cfg" + config_dir.mkdir() + herdr.sync_herdr_files("codex", config_dir, {}) + + proc = subprocess.run( + ["sh", str(config_dir / ".codex" / "herdr-agent-state.sh")], + input=json.dumps({"hook_event_name": event, "session_id": "s1"}), + capture_output=True, + text=True, + check=True, + timeout=15, + env={ + "PATH": os.environ["PATH"], + "HERDR_SOCKET_PATH": str(sock_path), + "HERDR_PANE_ID": "w1:p1", + "HOME": str(config_dir), + }, + ) + thread.join(timeout=5) + + assert proc.stdout == "" + assert received[0]["method"] == "pane.report_agent" + assert received[0]["params"]["state"] == state + assert received[0]["params"]["agent_session_id"] == "s1" + + +def test_codex_session_start_reports_session_then_idle(sock_dir: Path, tmp_path: Path) -> None: + if shutil.which("node") is None: + pytest.skip("node not available") + received: list[dict] = [] + sock_path = sock_dir / "herdr.sock" + thread = _serve_requests(sock_path, received, 2) + config_dir = tmp_path / "cfg" + config_dir.mkdir() + herdr.sync_herdr_files("codex", config_dir, {}) + + proc = subprocess.run( + ["sh", str(config_dir / ".codex" / "herdr-agent-state.sh")], + input=json.dumps( + { + "hook_event_name": "SessionStart", + "session_id": "s1", + "transcript_path": "/config/.codex/session.jsonl", + }, + ), + capture_output=True, + text=True, + check=True, + timeout=15, + env={ + "PATH": os.environ["PATH"], + "HERDR_SOCKET_PATH": str(sock_path), + "HERDR_PANE_ID": "w1:p1", + "HOME": str(config_dir), + }, + ) + thread.join(timeout=5) + + assert proc.stdout == "" + assert received[0]["method"] == "pane.report_agent_session" + assert received[0]["params"]["agent_session_id"] == "s1" + assert received[0]["params"]["agent_session_path"] == "/config/.codex/session.jsonl" + assert received[1]["method"] == "pane.report_agent" + assert received[1]["params"]["state"] == "idle" + + def test_release_agent_sends_socket_request(monkeypatch, sock_dir: Path) -> None: received: list = [] thread = _serve_one(sock_dir / "herdr.sock", received) @@ -714,6 +862,47 @@ def test_doctor_herdr_summary_lists_all_agents(monkeypatch, tmp_path: Path) -> N assert agent_name in result.output +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("missing", "MISSING hooks.json"), + ("malformed", "INVALID hooks.json"), + ("handler-absent", "MISSING handler"), + ("registered", "hooks.json"), + ], +) +def test_doctor_herdr_summary_distinguishes_codex_hook_states( + status: str, + expected: str, + monkeypatch, + tmp_path: Path, +) -> None: + from typer.testing import CliRunner + + from vibepod.cli import app + + config_dir = tmp_path / "agents" / "codex" + hooks_path = config_dir / ".codex" / "hooks.json" + if status != "missing": + hooks_path.parent.mkdir(parents=True) + content = "{broken" if status == "malformed" else json.dumps({"hooks": {}}) + hooks_path.write_text(content, encoding="utf-8") + if status == "registered": + herdr.register_codex_hooks(config_dir) + + monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_PANE_ID", "w1:p1") + monkeypatch.setenv("HERDR_TAB_ID", "w1:t1") + monkeypatch.setenv("HERDR_WORKSPACE_ID", "w1") + monkeypatch.setenv("HERDR_SOCKET_PATH", str(_make_socket(tmp_path / "herdr.sock"))) + monkeypatch.setenv("HERDR_BIN_PATH", "/nonexistent/herdr") + + result = CliRunner().invoke(app, ["doctor", "herdr"]) + assert result.exit_code == 0 + assert expected in result.output + + def test_release_agent_reports_error_reply(monkeypatch, sock_dir: Path, capsys) -> None: import threading