diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea54f36..92b3d31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -105,7 +105,7 @@ jobs: - features: auto-header baseImage: mcr.microsoft.com/devcontainers/base:ubuntu - # peon-ping - works on any base image with curl and python3 + # peon-ping - works on any base image with curl and jq - features: peon-ping baseImage: ubuntu:latest - features: peon-ping diff --git a/AGENTS.md b/AGENTS.md index 2761603..35d7636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ version bump" below. | `git-absorb` | 1.2.3 | git-absorb from GitHub releases | | `bitwarden-secrets-manager` | 1.2.4 | `bws` CLI from bitwarden/sdk-sm GitHub releases, token-only auth, no persisted state | | `dotfiles-sync` | 1.2.3 | Sync Git/SSH/GPG/npm/gh config from host; SSH key files opt-in (dependsOn helpers4-common) | -| `peon-ping` | 1.3.1 | AI agent sound notifications | +| `peon-ping` | 1.3.2 | AI agent sound notifications: one script, hooks ask the host's peon-ping relay to play a category (no packs in the container) | | `shell-history-per-project` | 1.2.3 | Persistent shell history (zsh/bash/fish) | | `org-workspace` | 1.0.1 | Clones every repo of a GitHub org into sibling `/workspaces` folders + generates/merges a multi-root `.code-workspace` (dependsOn helpers4-common, github-dev) | diff --git a/README.md b/README.md index 67969dc..c1cf160 100644 --- a/README.md +++ b/README.md @@ -255,13 +255,13 @@ Automatically configures file headers with customizable templates based on proje ### peon-ping -Installs [peon-ping](https://peonping.com/) for game character voice notifications when your AI coding agent finishes or needs permission. Includes the Peon Pet VS Code extension. +Plays your host's [peon-ping](https://peonping.com/) game-character sounds when your AI coding agent finishes or needs permission. Includes the Peon Pet VS Code extension. **Key benefits:** -- Sound notifications from 165+ packs (Warcraft, StarCraft, Portal, Zeldaโ€ฆ) -- Multi-IDE hooks: Claude Code, Copilot, Cursor, Codex, and more +- Uses the pack and volume of your host's peon-ping โ€” nothing to configure or keep in sync +- Hooks for Claude Code, Cursor and Codex, always all configured - Animated Peon Pet sidebar companion in VS Code -- Devcontainer-aware audio relay to host machine +- No download, no `initializeCommand`: sound goes through a relay you start once on your host - Non-interactive, idempotent installation [๐Ÿ“– Documentation](./src/peon-ping/README.md) @@ -337,7 +337,7 @@ Features from this repository are available via GitHub Container Registry. Refer | [git-absorb](./src/git-absorb) | Automatic absorption of staged changes into logical commits | [README](./src/git-absorb/README.md) | | [dotfiles-sync](./src/dotfiles-sync) | Sync local Git, SSH, GPG, and npm config โ€” works on macOS, Linux, WSL, Codespaces | [README](./src/dotfiles-sync/README.md) | | [bitwarden-secrets-manager](./src/bitwarden-secrets-manager) | `bws` CLI from Bitwarden's own GitHub releases, token-only auth, no persisted state | [README](./src/bitwarden-secrets-manager/README.md) | -| [peon-ping](./src/peon-ping) | AI agent sound notifications with multi-IDE hooks and Peon Pet extension | [README](./src/peon-ping/README.md) | +| [peon-ping](./src/peon-ping) | AI agent sound notifications played by your host's peon-ping, multi-IDE hooks and Peon Pet extension | [README](./src/peon-ping/README.md) | | [org-workspace](./src/org-workspace) | Clone an entire GitHub org into sibling workspace folders with one feature | [README](./src/org-workspace/README.md) | ## Dependency Graph diff --git a/src/peon-ping/README.md b/src/peon-ping/README.md index eb94967..14d1e6f 100644 --- a/src/peon-ping/README.md +++ b/src/peon-ping/README.md @@ -2,26 +2,18 @@ > Code name: `peon-ping` -Installs [peon-ping](https://peonping.com/) and the [Peon Pet](https://marketplace.visualstudio.com/items?itemName=smcqueen.vscode-peon-pet) VS Code extension for game character voice notifications when your AI coding agent finishes or needs permission. - -Supports **Claude Code**, **GitHub Copilot**, **Cursor**, **OpenAI Codex**, and [many more IDEs](https://github.com/PeonPing/peon-ping#multi-ide-support). +Plays your host's [peon-ping](https://peonping.com/) sounds when an AI coding agent in the +devcontainer finishes or needs permission, with hooks for Claude Code, Cursor and OpenAI Codex plus +the [Peon Pet](https://marketplace.visualstudio.com/items?itemName=smcqueen.vscode-peon-pet) +VS Code sidebar companion. Nothing to configure and nothing downloaded: the sound, the pack and the +volume all come from peon-ping on your **host**. > **Also included automatically:** repairs broken host paths in your git config and restores > your SSH commit-signing key on every attach, on both local and cloud containers, with nothing > to set up on your end โ€” see [`helpers4-common`](../helpers4-common) for how it works. -## Features - -- **Sound notifications**: Warcraft, StarCraft, Portal, Zelda and 165+ sound packs -- **Multi-IDE hooks**: Claude Code (built-in), Copilot, Cursor, Codex via adapters -- **Peon Pet extension**: Animated orc sidebar companion reacting to agent events -- **Devcontainer-aware**: Routes audio to host via relay (`host.docker.internal:19998`), automatically patched to resolve on native Linux Docker too โ€” see "Audio in Devcontainers" below -- **Non-interactive**: Fully automated, idempotent installation - ## Usage -### Basic Usage - ```json { "features": { @@ -30,254 +22,98 @@ Supports **Claude Code**, **GitHub Copilot**, **Cursor**, **OpenAI Codex**, and } ``` -This installs peon-ping with the default 5 packs (peon, peasant, sc_kerrigan, sc_battlecruiser, glados), registers Claude Code hooks, and installs the Peon Pet VS Code extension. - -### With All Packs - -```json -{ - "features": { - "ghcr.io/helpers4/devcontainer/peon-ping:1": { - "packs": "all" - } - } -} -``` - -### Copilot Only + Specific Packs - -```json -{ - "features": { - "ghcr.io/helpers4/devcontainer/peon-ping:1": { - "packs": "peon,glados,murloc", - "ideSetup": "vscode", - "volume": "0.3" - } - } -} -``` - -## Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `packs` | string | `default` | Sound packs: `default` (5 curated), `all` (165+), or CSV (e.g. `peon,glados,murloc`) | -| `packsLang` | string | `""` | Restrict pack selection to language(s), e.g. `fr` or `en,fr` โ€” see "Choosing a pack" below | -| `noRc` | boolean | `true` | Skip `.bashrc`/`.zshrc` modifications (recommended for devcontainers) | -| `ideSetup` | string | `vscode` | IDEs to configure: `all` (vscode + cursor + codex), `none`, or CSV (e.g. `vscode,cursor`) | -| `volume` | string | `0.5` | Default volume level (0.0โ€“1.0) | +No options. Then [start the relay on your host](#start-the-relay-on-your-host) โ€” the only step left +to you. -## Choosing a pack - -`packs` already takes a single value (`"glados"`, `"zelda"`, a CSV list, `default`, or `all`), so there's no extra option needed to pick one. The actual friction is finding a name among the ~165 packs in the registry (Warcraft, StarCraft, Red Alert, Portal, Zelda, Dota 2, Helldivers 2, Elder Scrolls, and more) โ€” a static list here would just go stale. Use the tools built for that instead: - -```bash -peon packs search # e.g. `peon packs search zelda` -peon packs use --install # try one immediately, install + switch in one step -``` - -Or browse with audio previews at [openpeon.com/packs](https://openpeon.com/packs). - -Prefer a language over a specific franchise? Set `packsLang` instead of hunting for names: - -```jsonc -{ - "features": { - "ghcr.io/helpers4/devcontainer/peon-ping:1": { - "packsLang": "fr" - } - } -} -``` +## How it works -This matches by each pack's own language metadata (e.g. `peon_fr`, `peasant_fr`), not by -guessing at name patterns โ€” the installer's own `--lang` flag does the filtering. Leaving -`packs` at its default while setting `packsLang` searches the full registry instead of just -the 5 franchise picks below, since those 5 don't all have a matching-language variant. +The container has no sound files and no peon-ping install of its own, only one script, +`/usr/local/bin/peon-ping`, with three subcommands: -The `default` bundle is 5 packs: +- **`hook`** โ€” what the agents call. It maps the event to a sound *category* (`session.start`, + `task.complete`, `input.required`, `task.error`) and asks the relay on your host to play it + (`GET host.docker.internal:19998/play?category=โ€ฆ`). The host picks a sound from its active pack, + at its volume, and plays it with its own audio. It also records the event in + `~/.claude/hooks/peon-ping/.state.json`, which Peon Pet polls. It never blocks the agent and + exits 0 whether or not the relay is running. +- **`register`** (`postCreateCommand`, once, after `claude-dev` has set up its `~/.claude`) โ€” adds + the hook to `~/.claude/settings.json` (Claude Code), `~/.cursor/hooks.json` (Cursor) and + `~/.codex/config.toml` (Codex), always all three. Existing hooks are never overwritten, and a + file that can't be parsed is left untouched with a warning. +- **`check`** (`postStartCommand`) โ€” makes `host.docker.internal` resolve (below) and tells you if + the relay isn't running. -| Pack | Franchise | -|------|-----------| -| `peon` | Warcraft (Orc Peon) | -| `peasant` | Warcraft (Human Peasant) | -| `sc_kerrigan` | StarCraft (Sarah Kerrigan) | -| `sc_battlecruiser` | StarCraft (Battlecruiser) | -| `glados` | Portal (GLaDOS) | +Changing pack or volume on your host (`peon packs use โ€ฆ`, `peon volume โ€ฆ`) applies to every +devcontainer immediately, with nothing to keep in sync. -## Audio in Devcontainers +## Start the relay on your host -peon-ping auto-detects devcontainer environments and routes audio to your host machine via a lightweight relay. **You must start the relay on your host:** +A Feature can't run anything on your host, so the relay, the one piece that must run there, is +yours to start. On your **host**, with [peon-ping](https://peonping.com/) installed +(`brew install PeonPing/tap/peon-ping` or `curl -fsSL https://peonping.com/install | bash`): ```bash -# On your HOST machine โ€” not inside the container -peon relay --daemon +peon relay --daemon # start in background +peon relay --status # check it +peon relay --stop # stop it ``` -The container sends audio requests to `host.docker.internal:19998`. - -**On native Linux Docker (not Docker Desktop), `host.docker.internal` doesn't resolve out of the -box** โ€” Docker Desktop (macOS/Windows) injects that DNS entry automatically, plain Linux Docker -doesn't. This feature patches it automatically on every container start (`postStartCommand`): -it reads the container's own default gateway from `/proc/net/route` โ€” the same IP -`--add-host=host.docker.internal:host-gateway` would have resolved to โ€” and adds it to the -container's `/etc/hosts` directly. Nothing to configure; on Docker Desktop it's a no-op, since -`host.docker.internal` already resolves there. - -If that patch can't run for some reason (`/etc/hosts` not writable, `sudo` unavailable โ€” check the -container's startup log for a `peon-ping: could not write /etc/hosts` warning), fall back to -adding this to your `devcontainer.json` and rebuilding: +Until it runs, the container prints `peon-ping: no sound this session` in its start-up log. +Requires a peon-ping version whose relay supports `/play?category=` (current `main`). -### Coexisting with `claude-dev` +### `host.docker.internal` on native Linux Docker -The [`claude-dev`](../claude-dev) feature persists `~/.claude` across rebuilds via a Docker -volume, linked into place once at container creation. A named volume isn't mounted yet during -the *image build* that runs `install.sh` โ€” only once the container is actually created โ€” so -installing straight into `~/.claude` at build time, like the upstream installer does by -default, would land in a spot that isn't the persistent one yet. - -peon-ping sidesteps this by not installing at build time at all: the actual install (binary, -packs, Claude Code hooks) runs once via `postCreateCommand` -(`install-claude-hooks.sh`) โ€” after the container, and any mounted volume, already exist. -`installsAfter: claude-dev` (`devcontainer-feature.json`) orders it after claude-dev's own -`postCreateCommand`, so `~/.claude` is already whatever it's going to be for this container by -the time peon-ping installs into it โ€” its own persistent volume if claude-dev is present, an -ordinary directory otherwise. Either way, peon-ping just installs normally, straight into the -real `~/.claude` โ€” no redirection, no relinking, nothing claude-dev-specific. No configuration -needed; this is the same install path whether claude-dev is installed or not. +Docker Desktop (macOS/Windows) resolves `host.docker.internal` by itself; plain Linux Docker +doesn't. `peon-ping check` reads the container's default gateway from `/proc/net/route` โ€” the IP +`--add-host=host.docker.internal:host-gateway` would give โ€” and adds it to `/etc/hosts`. It's a +no-op on Docker Desktop. If it can't write `/etc/hosts`, add this to your `devcontainer.json` +instead: ```jsonc -{ - "runArgs": ["--add-host=host.docker.internal:host-gateway"] -} -``` - -A Feature can't add `runArgs` itself โ€” it's only read from the consumer's top-level -`devcontainer.json` โ€” which is exactly why the automatic `/etc/hosts` patch above exists. - -### Testing the audio path - -1. On the **host**: `peon relay --daemon`, then `peon relay --status` to confirm it's listening. -2. Inside the **container**: `getent hosts host.docker.internal` should print an IP (previously - empty on native Linux Docker before this feature's `postStartCommand` patch runs). -3. Inside the **container**: check the relay port is actually reachable, not just the hostname - resolving โ€” e.g. `(echo > /dev/tcp/host.docker.internal/19998) 2>&1 && echo reachable || echo unreachable`. - If this says `unreachable` even after step 2 resolves, the relay on the host is likely bound to - `127.0.0.1` only (not visible from the container's network) rather than a devcontainer/Feature-side - problem โ€” check `peon relay --status` output on the host for its bind address. -4. Trigger a real notification (finish an agent turn) and confirm you hear it. - -If step 3 fails, that's outside what this feature (or any devcontainer Feature) can fix โ€” it's the -host-side relay's own bind address, controlled by the `peon` CLI itself, not this repo. - -### Relay Commands - -```bash -peon relay --daemon # Start in background -peon relay --stop # Stop relay -peon relay --status # Check status -peon relay --port=12345 # Custom port -``` - -> **Note**: Install peon-ping on your host machine first: `brew install PeonPing/tap/peon-ping` (macOS) or `curl -fsSL https://peonping.com/install | bash` - -## IDE-Specific Setup - -### Claude Code - -Hooks are registered automatically by the peon-ping installer in `~/.claude/settings.json`. No extra configuration needed. - -### GitHub Copilot - -The feature installs a helper script. Run it from your workspace root to generate `.github/hooks/hooks.json`: - -```bash -peon-ping-copilot-setup +{ "runArgs": ["--add-host=host.docker.internal:host-gateway"] } ``` -Or add it to your devcontainer.json: +On Linux the relay's default bind (`127.0.0.1`) isn't reachable through that gateway IP: start it +with `--bind=` (`docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}'`) +rather than `0.0.0.0`, so it isn't exposed to your whole LAN. -```json -{ - "postCreateCommand": "peon-ping-copilot-setup" -} -``` - -This creates hooks for `SessionStart`, `UserPromptSubmit`, `PostToolUse`, and `Stop` events using the Copilot adapter. +### GitHub Codespaces and other cloud workspaces -### Cursor +There is no host to reach, so `check` does nothing and the hooks silently no-op. -When `setupCursorHooks` is `true`, hooks are written to `~/.cursor/hooks.json` automatically. Events: `afterAgentResponse`, `stop`. +## Troubleshooting -### OpenAI Codex +1. Host: `peon relay --status`. +2. Container: `getent hosts host.docker.internal` prints an IP. +3. Container: `curl -s host.docker.internal:19998/health` prints `OK`. If 2 works but this + doesn't, the relay is bound to `127.0.0.1` only โ€” see the bind note above. +4. Container: `echo '{"hook_event_name":"Stop"}' | peon-ping hook claude` should play a sound. -When `setupCodexHooks` is `true`, the notify config is added to `~/.codex/config.toml` automatically. +## Other agents -### Other IDEs - -peon-ping provides adapters for [15+ IDEs](https://github.com/PeonPing/peon-ping#multi-ide-support) including Amp, Gemini CLI, Windsurf, Kiro, OpenCode, and more. After installation, adapters are available at `~/.claude/hooks/peon-ping/adapters/`. +GitHub Copilot's VS Code agent hooks read `~/.claude/settings.json`, so it should already be +covered by the Claude Code registration (per Peon Pet's documentation; not confirmed here). Other +agents aren't configured. ## Peon Pet Extension -The [Peon Pet](https://marketplace.visualstudio.com/items?itemName=smcqueen.vscode-peon-pet) VS Code extension adds an animated orc to your sidebar that reacts to peon-ping events. It polls `~/.claude/hooks/peon-ping/.state.json` every 200ms โ€” no daemon needed. - -Settings: -- `peon-pet.size`: `small`, `medium` (default), or `large` -- `peon-pet.character`: `orc` (default) - -## Quick Controls (Inside Container) - -```bash -peon status # Check if active -peon pause # Mute sounds -peon resume # Unmute -peon volume 0.3 # Change volume -peon packs use glados # Switch pack -peon packs list # List installed packs -``` +The [Peon Pet](https://marketplace.visualstudio.com/items?itemName=smcqueen.vscode-peon-pet) VS Code +extension is installed automatically: an animated orc in the sidebar reacting to agent events. +Settings: `peon-pet.size` (`small`, `medium` default, `large`) and `peon-pet.character`. ## Version History -- **v1.3.1**: Simplified v1.3.0's claude-dev coexistence fix โ€” replaced the redirect-into-a- - private-directory-then-relink dance (`CLAUDE_CONFIG_DIR`, a stable `claude-home` path, - `seed-claude-hooks.sh` re-linking hooks/skills and rewriting/merging a settings.json - fragment) with a plain, unconditional install into the real `~/.claude`, deferred to a new - `install-claude-hooks.sh` running once via `postCreateCommand` instead of at image build - time. `postCreateCommand` runs after the container (and any mounted volume) already exists, - ordered after claude-dev's own via `installsAfter` โ€” by the time it runs, `~/.claude` is - already whatever it's going to be for this container, so there's nothing left to redirect - or relink around. peon-ping installs the same way whether claude-dev is present or not โ€” no - claude-dev-specific code path anymore. See "Coexisting with claude-dev" below. -- **v1.3.0**: Fixed peon-ping never actually working for Claude Code when the `claude-dev` - feature is also installed โ€” its `postStartCommand` replaces `~/.claude` with a symlink to a - persistent volume on every start, which silently discarded everything peon-ping had installed - at build time (including the `peon` binary itself, leaving `command not found`). peon-ping now - installs into a per-container path claude-dev's swap never touches, and a new - `seed-claude-hooks.sh` postStartCommand (ordered after claude-dev via `installsAfter`) re-links - it into the real `~/.claude` and merges its Claude Code hook entries into the real - `settings.json` โ€” see "Coexisting with claude-dev" above. No behavior change when claude-dev - isn't installed. -- **v1.2.3**: Removes the manual `runArgs` step on native Linux Docker โ€” a new `postStartCommand` - (`patch-hosts.sh`) derives the container's default gateway from `/proc/net/route` (the same IP - `--add-host=host.docker.internal:host-gateway` would have resolved to) and adds it to the - container's own `/etc/hosts` directly, entirely inside the container. Not a new capability for - users who'd already added that `runArgs` line โ€” the audio relay worked the same either way; this - just makes it work without that step. The manual edit stays documented as a fallback for the - rare case where `/etc/hosts` isn't writable and `sudo` isn't available either. -- **v1.2.2**: Documentation only, no functional change โ€” the previous wording sweep made the - JSON `description` field far too long, shifting focus away from the feature itself onto the - self-heal side benefit. Shortened to 5 words and kept generic (no implementation detail like - "git config"), matching the original's brevity and level of detail. -- **v1.2.1**: Documentation only, no functional change โ€” the self-heal callout above (and the - JSON `description` field) led with internal jargon ("helpers4's self-heal") instead of the - actual benefit; reworded to lead with what it does, with the full mechanism staying in - [`helpers4-common`](../helpers4-common)'s own README. -- **v1.2.0**: Documentation only, no functional change โ€” mentions that `helpers4-common`'s - automatic git-config self-heal (see above) now comes along with this feature. -- **v1.1.0**: Switched from an inline copy of `helpers4-common`'s bootstrap (user detection, apt - helpers) to a direct `dependsOn` on the `helpers4-common` feature โ€” no behavior change, just a - single source of truth for that logic instead of a copy every feature had to keep in sync. -- **v1.0.6**: Fixed a build failure on WSL2-backed Docker Desktop hosts: the upstream installer's platform detection misreads a BuildKit `RUN` sandbox as raw WSL (no `/.dockerenv` yet, but the kernel still reports "microsoft") and then hard-requires `powershell.exe`, which isn't available in that sandbox. `install.sh` now sets `REMOTE_CONTAINERS=true` for the installer subshell, forcing correct devcontainer detection. -- **v1.0.5**: Added `packsLang` โ€” restrict pack selection to language(s) instead of naming packs directly, e.g. `packsLang: "fr"`. Passed straight through to the upstream installer's own `--lang` flag, which already understood per-pack language metadata; this just exposes it as a feature option. -- **v1.0.4**: Fixed a Python syntax error in `install.sh`'s Copilot hooks merge path (`peon-ping-copilot-setup`) โ€” it crashed every time it ran against an existing `.github/hooks/hooks.json`. That helper now shares its merge logic with the same `merge_hooks_json` used for Claude Code/Cursor instead of re-deriving it, and an existing `hooks.json` that isn't valid JSON gets backed up to `.bak` instead of silently discarded. Corrected the "Audio in Devcontainers" docs: `host.docker.internal` doesn't resolve on native Linux Docker without `runArgs: ["--add-host=host.docker.internal:host-gateway"]` in the consumer's `devcontainer.json`, which a Feature can't add on its own. Added a "Choosing a pack" section pointing at `peon packs search` and `openpeon.com/packs` instead of adding a preset option โ€” `packs` was already simple enough. +- **v1.3.2**: The container no longer installs peon-ping or any sound pack. Hooks now ask your + host's relay to play a sound *category* and the host picks pack and volume, so there is nothing + to mirror or configure. Everything is one script, `peon-ping` (`hook`, `register`, `check`). + Removed the `packs`, `packsLang`, `volume`, `noRc` and `ideSetup` options (Claude Code, Cursor + and Codex are always configured), the `peon` CLI and packs inside the container, and the + `peon-ping-copilot-setup` helper. Breaking: drop those options from your `devcontainer.json`. + Starting the relay on your host is still a manual step, now reported by `check` at each start + instead of a one-off install message. +- **v1.3.1**: Installed via `postCreateCommand` instead of at image build time, so it works + alongside `claude-dev`'s persistent `~/.claude` volume. +- **v1.2.3**: `host.docker.internal` patched into `/etc/hosts` automatically on native Linux Docker. +- **v1.1.0**: Depends on `helpers4-common` instead of an inline copy of its bootstrap. +- **v1.0.6**: Fixed the build on WSL2-backed Docker Desktop hosts. +- **v1.0.5**: Added `packsLang` *(removed in v1.3.2)*. diff --git a/src/peon-ping/devcontainer-feature.json b/src/peon-ping/devcontainer-feature.json index d87e328..e32097c 100644 --- a/src/peon-ping/devcontainer-feature.json +++ b/src/peon-ping/devcontainer-feature.json @@ -1,38 +1,11 @@ { "id": "peon-ping", - "version": "1.3.1", + "version": "1.3.2", "name": "Peon Ping โ€” AI Agent Sound Notifications", - "description": "Installs peon-ping and the Peon Pet VS Code extension for game character voice notifications when your AI coding agent finishes or needs permission. Supports VS Code/Copilot, Cursor, and Codex with audio relay for devcontainers. Also self-heals your devcontainer setup.", + "description": "Plays your host's peon-ping sounds when your AI coding agent finishes or needs permission โ€” Claude Code, Cursor and Codex hooks, plus the Peon Pet VS Code extension. Nothing to configure: the sound and pack come from your host. Also self-heals your devcontainer setup.", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/src/peon-ping", "licenseURL": "https://github.com/helpers4/devcontainer/blob/main/LICENSE", "keywords": ["helpers4", "peon", "audio", "notifications", "ai"], - "options": { - "packs": { - "type": "string", - "default": "default", - "description": "Sound packs to install: 'default' (peon, peasant, sc_kerrigan, sc_battlecruiser, glados), 'all', or a comma-separated list (e.g. 'peon,glados,murloc')" - }, - "packsLang": { - "type": "string", - "default": "", - "description": "Restrict pack selection to language(s), e.g. 'fr' or 'en,fr' โ€” passed straight through to the installer's own --lang flag, which understands per-pack language metadata (not just name matching, e.g. matches 'peon_fr'). Left empty, no language filtering happens. If set while 'packs' is still 'default', searches the full pack registry instead of just the 5 default franchise picks โ€” those 5 may not all have a matching-language variant." - }, - "noRc": { - "type": "boolean", - "default": true, - "description": "Skip .bashrc/.zshrc modifications (recommended for devcontainers)" - }, - "ideSetup": { - "type": "string", - "default": "vscode", - "description": "IDEs to configure: 'all' (vscode + cursor + codex), 'none', or a comma-separated list (e.g. 'vscode,cursor')" - }, - "volume": { - "type": "string", - "default": "0.5", - "description": "Default volume level (0.0 โ€“ 1.0)" - } - }, "customizations": { "vscode": { "extensions": [ @@ -43,8 +16,8 @@ } } }, - "postCreateCommand": "/usr/local/share/peon-ping/install-claude-hooks.sh", - "postStartCommand": "/usr/local/share/peon-ping/patch-hosts.sh", + "postCreateCommand": "/usr/local/bin/peon-ping register", + "postStartCommand": "/usr/local/bin/peon-ping check", "installsAfter": [ "ghcr.io/devcontainers/features/common-utils", "ghcr.io/helpers4/devcontainer/claude-dev" diff --git a/src/peon-ping/install.sh b/src/peon-ping/install.sh index 853c415..823537a 100644 --- a/src/peon-ping/install.sh +++ b/src/peon-ping/install.sh @@ -4,393 +4,28 @@ # Copyright (C) 2025 baxyz # SPDX-License-Identifier: LGPL-3.0-or-later # -# Installs peon-ping and configures multi-IDE hooks for AI agent sound notifications +# Installs the peon-ping script. Nothing is downloaded and nothing is written under ~/.claude at +# build time: the hooks are registered at container creation (postCreateCommand), after +# claude-dev has put its persistent ~/.claude volume in place. See README "How it works". set -euo pipefail # shellcheck source=/dev/null . /usr/local/share/helpers4/common.sh -# Feature options (env vars auto-generated from devcontainer-feature.json) -PACKS="${PACKS:-"default"}" -PACKS_LANG="${PACKSLANG:-""}" -NO_RC="${NORC:-"true"}" -IDE_SETUP="${IDESETUP:-"vscode"}" -VOLUME="${VOLUME:-"0.5"}" +h4_require_root -# Validate volume is a valid float between 0.0 and 1.0 -if ! echo "${VOLUME}" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then - echo "โš ๏ธ Invalid volume '${VOLUME}', falling back to 0.5" - VOLUME="0.5" -elif ! awk "BEGIN { v=${VOLUME}; exit (v >= 0.0 && v <= 1.0) ? 0 : 1 }"; then - echo "โš ๏ธ Volume '${VOLUME}' out of range [0.0โ€“1.0], clamping to 0.5" - VOLUME="0.5" -fi - -if [ "$(id -u)" -ne 0 ]; then - echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - -h4_detect_user -h4_resolve_home - -# Clean up cleanup() { rm -rf /var/lib/apt/lists/* } - trap cleanup EXIT -# Ensure apt is in non-interactive mode export DEBIAN_FRONTEND=noninteractive echo "๐ŸŽฎ Configuring peon-ping feature..." -echo " Username: ${USERNAME}" -echo " Packs: ${PACKS}" -[ -n "${PACKS_LANG}" ] && echo " Packs language filter: ${PACKS_LANG}" -echo " Volume: ${VOLUME}" - -# โ”€โ”€ Prerequisites โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "๐Ÿ”ง Installing prerequisites..." -h4_ensure_packages curl ca-certificates python3 alsa-utils - -# โ”€โ”€ Build the upstream installer's argument list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -INSTALLER_ARGS="--global" - -if [ "${PACKS}" = "all" ]; then - INSTALLER_ARGS="${INSTALLER_ARGS} --all" -elif [ "${PACKS}" != "default" ]; then - INSTALLER_ARGS="${INSTALLER_ARGS} --packs=${PACKS}" -elif [ -n "${PACKS_LANG}" ]; then - # packsLang set but packs left at "default" (5 fixed franchise picks) โ€” - # search the full registry instead, or --lang would just narrow those - # 5 specific pack IDs down to whichever happen to match, likely none. - INSTALLER_ARGS="${INSTALLER_ARGS} --all" -fi - -if [ -n "${PACKS_LANG}" ]; then - INSTALLER_ARGS="${INSTALLER_ARGS} --lang=${PACKS_LANG}" -fi - -if [ "${NO_RC}" = "true" ]; then - INSTALLER_ARGS="${INSTALLER_ARGS} --no-rc" -fi - -# โ”€โ”€ Generate the postCreateCommand install script โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# -# The actual peon-ping install (binary, sound packs, Claude Code hooks) runs once at -# container creation instead of here at image build time, ordered after claude-dev via -# installsAfter โ€” so by the time it runs, ~/.claude is already whatever it's going to be for -# this container (claude-dev's persistent volume if that feature is present, an ordinary -# directory otherwise). Either way, this installs straight into the real ~/.claude with no -# special-casing: nothing here needs to know or care which case it is. -# -# This also sidesteps a hazard that has nothing to do with claude-dev specifically: a named -# volume isn't mounted yet during the image build that runs this script (only once the -# container is actually created), so anything written into ~/.claude here would just be -# baked into an image layer โ€” invisible to whatever ends up mounted at that path afterward, -# for any feature that does so, not just this one. -SCRIPT="/usr/local/share/peon-ping/install-claude-hooks.sh" -mkdir -p "$(dirname "${SCRIPT}")" - -{ - cat << 'HEADER' -#!/usr/bin/env bash -# This file is part of helpers4. -# Copyright (C) 2025 baxyz -# SPDX-License-Identifier: LGPL-3.0-or-later -# -# Runs once, at container creation (postCreateCommand) โ€” ordered after claude-dev's own -# postCreateCommand via installsAfter (devcontainer-feature.json), so ~/.claude is already -# whatever it's going to be for this container by the time this runs. Installs peon-ping -# normally into it, no redirection or relinking needed either way. -set -euo pipefail -HEADER - printf 'USERNAME=%q\n' "${USERNAME}" - printf 'USER_HOME=%q\n' "${USER_HOME}" - printf 'INSTALLER_ARGS=%q\n' "${INSTALLER_ARGS}" - printf 'VOLUME=%q\n' "${VOLUME}" -} > "${SCRIPT}" - -cat >> "${SCRIPT}" << 'EOF' - -echo "๐ŸŽฎ Installing peon-ping..." - -# Run directly โ€” no `su -`. install.sh (build time, root) needed `su - "${USERNAME}"` to drop -# privileges; this script runs at container creation as postCreateCommand, which already -# executes as the container's remoteUser/containerUser (USERNAME, resolved by the same -# h4_detect_user this script's generator used). `su - "${USERNAME}"` run BY that same non-root -# user requires their password โ€” there's no sudoers-style passwordless path for `su` itself โ€” -# so it fails with "Authentication failure" every time, silently swallowed by the `||` below, -# and the peon binary is never actually installed. Verified directly: `su - -c true` run -# as that same non-root user fails the same way. claude-dev's own postCreateCommand script -# avoids this same trap by never using `su` either. -# -# The installer may exit non-zero if its sound test fails (no audio device attached yet at -# container creation). Tolerate that and verify the actual installation ourselves. -# -# REMOTE_CONTAINERS=true forces peon-ping's own platform detection down its "devcontainer" -# branch. Without it, a WSL2-backed Docker Desktop host has none of `/.dockerenv`/$CODESPACES -# yet still inherits "microsoft" in /proc/version from the WSL2 kernel, so peon-ping -# misdetects "wsl" and hard-requires powershell.exe (unavailable here). -# shellcheck disable=SC2086 -curl -fsSL https://raw.githubusercontent.com/PeonPing/peon-ping/main/install.sh | REMOTE_CONTAINERS=true bash -s -- ${INSTALLER_ARGS} || \ - echo "โš ๏ธ peon-ping installer exited with errors (sound test failure at container creation is expected)" - -PEON_BIN="${USER_HOME}/.local/bin/peon" -if [ ! -x "${PEON_BIN}" ] && ! command -v peon > /dev/null 2>&1; then - echo "โŒ peon binary not found after installation โ€” install truly failed" - exit 1 -fi -echo " โœ… peon binary found" - -PEON_CONFIG="${USER_HOME}/.claude/hooks/peon-ping/config.json" -if [ -f "${PEON_CONFIG}" ] && command -v python3 > /dev/null 2>&1; then - echo "๐Ÿ”ง Setting volume to ${VOLUME}..." - python3 << PYEOF -import json, sys - -path = "${PEON_CONFIG}" -try: - with open(path) as f: - cfg = json.load(f) - cfg["volume"] = max(0.0, min(1.0, float("${VOLUME}"))) - with open(path, "w") as f: - json.dump(cfg, f, indent=2) - f.write("\n") -except (json.JSONDecodeError, ValueError, OSError) as e: - print(f"โš ๏ธ Could not update volume in {path}: {e}", file=sys.stderr) -PYEOF -fi - -echo "" -echo "๐ŸŽฎ peon-ping installation complete!" -echo "" -echo " โš ๏ธ IMPORTANT โ€” Audio in devcontainers:" -echo " Start the relay on your HOST machine:" -echo "" -echo " peon relay --daemon" -echo "" -echo " The container routes audio to host.docker.internal:19998 automatically." -echo "" -EOF - -chmod +x "${SCRIPT}" -echo " โœ… Installed ${SCRIPT} (runs at container creation)" - -# โ”€โ”€ Hook configuration helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -# Parse ideSetup option into a lookup function -# "all" โ†’ every IDE, "none" โ†’ skip, csv โ†’ only listed -# "vscode" and "copilot" are treated as synonyms -ide_enabled() { - local ide="$1" - local setup="${IDE_SETUP}" - case "${setup}" in - all) return 0 ;; - none) return 1 ;; - *) echo ",${setup}," | sed 's/copilot/vscode/gi; s/vscode/vscode/gi' | grep -qi ",${ide}," ;; - esac -} - -# Merge peon-ping hooks into a JSON hooks file (idempotent). -# Usage: merge_hooks_json -merge_hooks_json() { - local target="$1" - local new_hooks="$2" - - python3 << PYEOF -import json, os, sys - -target_path = "${target}" -try: - new_hooks = json.loads("""${new_hooks}""") -except json.JSONDecodeError as e: - print(f"โš ๏ธ Invalid hooks JSON: {e}", file=sys.stderr) - sys.exit(0) - -try: - if os.path.exists(target_path): - with open(target_path) as f: - data = json.load(f) - else: - os.makedirs(os.path.dirname(target_path), exist_ok=True) - data = {"version": 1, "hooks": {}} -except json.JSONDecodeError as e: - # File exists but isn't valid JSON โ€” back it up instead of silently - # discarding whatever the user had in it (could be hooks for other - # tools). Only start fresh once the original content is preserved. - backup_path = target_path + ".bak" - os.replace(target_path, backup_path) - print(f"โš ๏ธ {target_path} wasn't valid JSON ({e}); backed up to {backup_path} and starting fresh", file=sys.stderr) - data = {"version": 1, "hooks": {}} -except OSError as e: - print(f"โš ๏ธ Could not read {target_path}, creating fresh: {e}", file=sys.stderr) - os.makedirs(os.path.dirname(target_path), exist_ok=True) - data = {"version": 1, "hooks": {}} - -existing_hooks = data.setdefault("hooks", {}) - -for event, entries in new_hooks.items(): - event_list = existing_hooks.setdefault(event, []) - existing_cmds = [e.get("bash", e.get("command", "")) for e in event_list] - for entry in entries: - if not any("peon-ping" in c for c in existing_cmds): - event_list.append(entry) - -data["hooks"] = existing_hooks -try: - with open(target_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") -except OSError as e: - print(f"โš ๏ธ Could not write {target_path}: {e}", file=sys.stderr) -PYEOF -} - -# โ”€โ”€ GitHub Copilot hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if ide_enabled vscode; then - echo "๐Ÿ”ง Configuring GitHub Copilot hooks..." - - # Create a helper script that generates .github/hooks/hooks.json in the - # current workspace. Users can call it manually or via postCreateCommand. - # It runs later, standalone, against a workspace path that doesn't exist - # yet at image-build time, so it can't call this install.sh's own - # merge_hooks_json โ€” it carries an embedded copy instead (kept identical - # on purpose; see merge_hooks_json above). - cat > /usr/local/bin/peon-ping-copilot-setup << 'SETUPEOF' -#!/usr/bin/env bash -# Generate .github/hooks/hooks.json for GitHub Copilot agent mode. -# Run from the workspace root or pass the target directory as $1. -set -e - -TARGET_DIR="${1:-.}" -HOOKS_DIR="${TARGET_DIR}/.github/hooks" -HOOKS_FILE="${HOOKS_DIR}/hooks.json" - -mkdir -p "${HOOKS_DIR}" - -merge_hooks_json() { - local target="$1" - local new_hooks="$2" - - python3 << PYEOF -import json, os, sys - -target_path = "${target}" -try: - new_hooks = json.loads("""${new_hooks}""") -except json.JSONDecodeError as e: - print(f"โš ๏ธ Invalid hooks JSON: {e}", file=sys.stderr) - sys.exit(0) - -try: - if os.path.exists(target_path): - with open(target_path) as f: - data = json.load(f) - else: - os.makedirs(os.path.dirname(target_path), exist_ok=True) - data = {"version": 1, "hooks": {}} -except json.JSONDecodeError as e: - backup_path = target_path + ".bak" - os.replace(target_path, backup_path) - print(f"โš ๏ธ {target_path} wasn't valid JSON ({e}); backed up to {backup_path} and starting fresh", file=sys.stderr) - data = {"version": 1, "hooks": {}} -except OSError as e: - print(f"โš ๏ธ Could not read {target_path}, creating fresh: {e}", file=sys.stderr) - os.makedirs(os.path.dirname(target_path), exist_ok=True) - data = {"version": 1, "hooks": {}} - -existing_hooks = data.setdefault("hooks", {}) - -for event, entries in new_hooks.items(): - event_list = existing_hooks.setdefault(event, []) - existing_cmds = [e.get("bash", e.get("command", "")) for e in event_list] - for entry in entries: - if not any("peon-ping" in c for c in existing_cmds): - event_list.append(entry) - -data["hooks"] = existing_hooks -try: - with open(target_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") -except OSError as e: - print(f"โš ๏ธ Could not write {target_path}: {e}", file=sys.stderr) -PYEOF -} - -# VS Code hook event names (PascalCase, auto-mapped from lowerCamelCase by VS Code). -# See https://code.visualstudio.com/docs/copilot/customization/hooks#_hook-lifecycle-events -HOOKS_JSON='{ - "SessionStart": [{"type": "command", "bash": "bash ~/.claude/hooks/peon-ping/adapters/copilot.sh SessionStart"}], - "UserPromptSubmit": [{"type": "command", "bash": "bash ~/.claude/hooks/peon-ping/adapters/copilot.sh UserPromptSubmit"}], - "PostToolUse": [{"type": "command", "bash": "bash ~/.claude/hooks/peon-ping/adapters/copilot.sh PostToolUse"}], - "Stop": [{"type": "command", "bash": "bash ~/.claude/hooks/peon-ping/adapters/copilot.sh Stop"}] -}' - -merge_hooks_json "${HOOKS_FILE}" "${HOOKS_JSON}" - -echo "โœ… Copilot hooks written to ${HOOKS_FILE}" -SETUPEOF - chmod +x /usr/local/bin/peon-ping-copilot-setup - - echo " โœ… Helper installed: peon-ping-copilot-setup" - echo " Run it from your workspace root (or add to postCreateCommand) to generate .github/hooks/hooks.json" -fi - -# โ”€โ”€ Cursor hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if ide_enabled cursor; then - echo "๐Ÿ”ง Configuring Cursor hooks..." - - CURSOR_HOOKS_JSON='{ - "afterAgentResponse": [{"command": "bash ~/.claude/hooks/peon-ping/adapters/cursor.sh afterAgentResponse"}], - "stop": [{"command": "bash ~/.claude/hooks/peon-ping/adapters/cursor.sh stop"}] - }' - - CURSOR_HOOKS_FILE="${USER_HOME}/.cursor/hooks.json" - merge_hooks_json "${CURSOR_HOOKS_FILE}" "${CURSOR_HOOKS_JSON}" - chown -R "${USERNAME}:${USERNAME}" "${USER_HOME}/.cursor" 2>/dev/null || true - - echo " โœ… Cursor hooks written to ${CURSOR_HOOKS_FILE}" -fi - -# โ”€โ”€ OpenAI Codex hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if ide_enabled codex; then - echo "๐Ÿ”ง Configuring Codex hooks..." - - CODEX_CONFIG_DIR="${USER_HOME}/.codex" - CODEX_CONFIG="${CODEX_CONFIG_DIR}/config.toml" - - mkdir -p "${CODEX_CONFIG_DIR}" - - if [ -f "${CODEX_CONFIG}" ]; then - if ! grep -q "peon-ping" "${CODEX_CONFIG}"; then - printf '\nnotify = ["bash", "~/.claude/hooks/peon-ping/adapters/codex.sh"]\n' >> "${CODEX_CONFIG}" - fi - else - cat > "${CODEX_CONFIG}" << 'EOF' -notify = ["bash", "~/.claude/hooks/peon-ping/adapters/codex.sh"] -EOF - fi - - chown -R "${USERNAME}:${USERNAME}" "${CODEX_CONFIG_DIR}" 2>/dev/null || true - - echo " โœ… Codex hooks written to ${CODEX_CONFIG}" -fi -# โ”€โ”€ postStartCommand script โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +h4_ensure_packages curl ca-certificates jq -mkdir -p /usr/local/share/peon-ping -cp "$(dirname "$0")/patch-hosts.sh" /usr/local/share/peon-ping/patch-hosts.sh -chmod +x /usr/local/share/peon-ping/patch-hosts.sh +install -m 0755 "$(dirname "$0")/peon-ping" /usr/local/bin/peon-ping -echo "" -echo "๐ŸŽฎ peon-ping configuration complete โ€” the peon binary and Claude Code hooks install" -echo " once the container is created (see ${SCRIPT})." +echo "๐ŸŽฎ peon-ping ready โ€” hooks are registered at container creation." diff --git a/src/peon-ping/patch-hosts.sh b/src/peon-ping/patch-hosts.sh deleted file mode 100755 index 8df4943..0000000 --- a/src/peon-ping/patch-hosts.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -# This file is part of helpers4. -# Copyright (C) 2025 baxyz -# SPDX-License-Identifier: LGPL-3.0-or-later -# -# Runs on postStartCommand โ€” host.docker.internal doesn't resolve out of the box on native -# Linux Docker (only Docker Desktop injects that DNS entry automatically), which breaks the -# audio relay peon-ping depends on. Rather than requiring every consumer to add `runArgs: -# ["--add-host=host.docker.internal:host-gateway"]` to their own devcontainer.json, this derives -# the same IP โ€” the container's default gateway, exactly what that runArgs flag resolves to โ€” -# from /proc/net/route (no extra package needed, unlike `ip route`) and writes it to /etc/hosts -# directly, entirely inside the container's own filesystem namespace. -# -# Best-effort and idempotent: safe to run on every start, only warns if it can't fix it. - -command -v getent >/dev/null 2>&1 || exit 0 - -if getent hosts host.docker.internal >/dev/null 2>&1; then - exit 0 # already resolves โ€” Docker Desktop, or a previous run of this script -fi - -GATEWAY_HEX="$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route 2>/dev/null)" - -if [ -z "${GATEWAY_HEX}" ]; then - echo "โš ๏ธ peon-ping: could not determine the default gateway โ€” host.docker.internal will not resolve. See README 'Audio in Devcontainers' for the manual runArgs fallback." >&2 - exit 0 -fi - -# /proc/net/route stores the gateway as a little-endian hex IP โ€” reverse the byte order. -GATEWAY_IP="$(printf '%d.%d.%d.%d' \ - "0x${GATEWAY_HEX:6:2}" "0x${GATEWAY_HEX:4:2}" "0x${GATEWAY_HEX:2:2}" "0x${GATEWAY_HEX:0:2}")" - -ENTRY="${GATEWAY_IP} host.docker.internal" -if echo "${ENTRY}" >>/etc/hosts 2>/dev/null; then - echo "โœ… peon-ping: added host.docker.internal (${GATEWAY_IP}) to /etc/hosts" -elif command -v sudo >/dev/null 2>&1 && echo "${ENTRY}" | sudo tee -a /etc/hosts >/dev/null 2>&1; then - echo "โœ… peon-ping: added host.docker.internal (${GATEWAY_IP}) to /etc/hosts" -else - echo "โš ๏ธ peon-ping: could not write /etc/hosts โ€” host.docker.internal will not resolve. See README 'Audio in Devcontainers' for the manual runArgs fallback." >&2 -fi - -exit 0 diff --git a/src/peon-ping/peon-ping b/src/peon-ping/peon-ping new file mode 100755 index 0000000..94e269b --- /dev/null +++ b/src/peon-ping/peon-ping @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +# This file is part of helpers4. +# Copyright (C) 2025 baxyz +# SPDX-License-Identifier: LGPL-3.0-or-later +# +# peon-ping hook [arg] Called by the agents: asks the peon-ping relay +# on the host to play a sound category and +# records the event for Peon Pet. +# peon-ping register postCreateCommand: adds the hooks to the +# agents' config (idempotent). +# peon-ping check postStartCommand: makes host.docker.internal +# resolve and warns if the relay is down. + +set -u + +BIN="/usr/local/bin/peon-ping" +STATE_FILE="${HOME}/.claude/hooks/peon-ping/.state.json" +RELAY_HOST="${PEON_RELAY_HOST:-host.docker.internal}" +RELAY_PORT="${PEON_RELAY_PORT:-19998}" + +hook() { + local ide="${1:-claude}" arg="${2:-}" payload="" event category + + if [ "${ide}" = "codex" ]; then + payload="${arg}" + elif [ ! -t 0 ]; then + payload="$(timeout 1 cat 2>/dev/null)" + fi + field() { sed -n "s/.*\"$1\" *: *\"\([^\"]*\)\".*/\1/p" <<<"${payload}" | head -n 1; } + escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + + case "${ide}" in + codex) [ "$(field type)" = "agent-turn-complete" ] && event="Stop" || event="" ;; + cursor) [ "${arg}" = "stop" ] && event="Stop" || event="" ;; + *) event="$(field hook_event_name)" ;; + esac + + case "${event}" in + SessionStart) category="session.start" ;; + Stop) category="task.complete" ;; + PermissionRequest) category="input.required" ;; + PostToolUseFailure) category="task.error" ;; + UserPromptSubmit | PreCompact | SessionEnd) category="" ;; + *) return 0 ;; + esac + + if mkdir -p "$(dirname "${STATE_FILE}")" 2>/dev/null; then + printf '{"last_active": {"timestamp": %s, "event": "%s", "session_id": "%s", "cwd": "%s"}}\n' \ + "$(date +%s.%N)" "${event}" "$(escape "$(field session_id)")" "$(escape "$(field cwd)")" \ + >"${STATE_FILE}.tmp" 2>/dev/null && mv "${STATE_FILE}.tmp" "${STATE_FILE}" 2>/dev/null + fi + + if [ -n "${category}" ]; then + curl -sf --noproxy '*' -m 2 "http://${RELAY_HOST}:${RELAY_PORT}/play?category=${category}" \ + >/dev/null 2>&1 +add_hook() { + local file="$1" initial="$2" event="$3" entry="$4" merged + if [ ! -f "${file}" ]; then + mkdir -p "$(dirname "${file}")" && printf '%s\n' "${initial}" >"${file}" + fi + if ! merged="$(jq --arg e "${event}" --argjson entry "${entry}" ' + if ((.hooks[$e] // []) | tostring | contains("peon-ping hook")) then . + else .hooks[$e] = ((.hooks[$e] // []) + [$entry]) end' "${file}" 2>/dev/null)"; then + echo "โš ๏ธ peon-ping: ${file} isn't valid JSON โ€” left untouched" >&2 + return 0 + fi + printf '%s\n' "${merged}" >"${file}" +} + +register() { + local entry event codex="${HOME}/.codex/config.toml" + + entry="$(jq -nc --arg c "${BIN} hook claude" '{hooks: [{type: "command", command: $c}]}')" + for event in SessionStart UserPromptSubmit Stop PermissionRequest PostToolUseFailure PreCompact SessionEnd; do + add_hook "${HOME}/.claude/settings.json" '{}' "${event}" "${entry}" + done + + add_hook "${HOME}/.cursor/hooks.json" '{"version": 1}' stop \ + "$(jq -nc --arg c "${BIN} hook cursor stop" '{command: $c}')" + + # A top-level TOML key must precede every [table], so prepend rather than append. + if grep -qs 'peon-ping' "${codex}"; then + : + elif grep -Eqs '^[[:space:]]*notify[[:space:]]*=' "${codex}"; then + echo "โš ๏ธ peon-ping: ${codex} already has a notify command โ€” left untouched" >&2 + else + mkdir -p "$(dirname "${codex}")" + { printf 'notify = ["%s", "hook", "codex"]\n' "${BIN}"; cat "${codex}" 2>/dev/null; } >"${codex}.tmp" \ + && mv "${codex}.tmp" "${codex}" + fi + + echo "๐ŸŽฎ peon-ping: hooks registered for Claude Code, Cursor and Codex" +} + +check() { + # shellcheck source=/dev/null + . /usr/local/share/helpers4/common.sh 2>/dev/null || return 0 + h4_detect_cloud_env + [ "${IS_CLOUD_ENV}" = "true" ] && return 0 + + # Docker Desktop resolves host.docker.internal itself; native Linux Docker doesn't. Add the + # default gateway (what --add-host=host.docker.internal:host-gateway would resolve to), + # read from /proc/net/route as a little-endian hex IP. + if ! getent hosts host.docker.internal >/dev/null 2>&1; then + local hex ip + hex="$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route 2>/dev/null)" + if [ -n "${hex}" ]; then + ip="$(printf '%d.%d.%d.%d' "0x${hex:6:2}" "0x${hex:4:2}" "0x${hex:2:2}" "0x${hex:0:2}")" + echo "${ip} host.docker.internal" >>/etc/hosts 2>/dev/null \ + || echo "${ip} host.docker.internal" | sudo tee -a /etc/hosts >/dev/null 2>&1 \ + || echo "โš ๏ธ peon-ping: could not write /etc/hosts โ€” see README 'host.docker.internal on native Linux Docker'" >&2 + fi + fi + + curl -sf --noproxy '*' --connect-timeout 1 -m 2 "http://${RELAY_HOST}:${RELAY_PORT}/health" >/dev/null 2>&1 && return 0 + echo "๐Ÿ”‡ peon-ping: no sound this session โ€” the audio relay isn't running on your host." >&2 + echo " Start it on your HOST machine (not in this container): peon relay --daemon" >&2 +} + +case "${1:-}" in + hook) shift; hook "$@" ;; + register) register ;; + check) check ;; + *) echo "Usage: peon-ping " >&2; exit 2 ;; +esac +exit 0 diff --git a/test/peon-ping/scenarios.json b/test/peon-ping/scenarios.json index d03f70d..f69ce25 100644 --- a/test/peon-ping/scenarios.json +++ b/test/peon-ping/scenarios.json @@ -3,9 +3,7 @@ "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "features": { "claude-dev": {}, - "peon-ping": { - "packs": "peon" - } + "peon-ping": {} } } } diff --git a/test/peon-ping/test.sh b/test/peon-ping/test.sh index 2725004..b6feac4 100644 --- a/test/peon-ping/test.sh +++ b/test/peon-ping/test.sh @@ -8,85 +8,57 @@ set -e echo "Testing peon-ping feature..." -# Test 1: Check if peon binary is accessible -if command -v peon >/dev/null 2>&1; then - echo "โœ… PASS: peon binary is accessible" +if [ -x /usr/local/bin/peon-ping ]; then + echo "โœ… PASS: /usr/local/bin/peon-ping installed and executable" else - # Check common install location directly - PEON_BIN="${HOME}/.local/bin/peon" - if [ -x "${PEON_BIN}" ]; then - echo "โœ… PASS: peon binary found at ${PEON_BIN}" - else - echo "โŒ FAIL: peon binary not found" - exit 1 - fi -fi - -# Test 2: Check peon-ping runtime directory -PEON_DIR="${HOME}/.claude/hooks/peon-ping" -if [ -d "${PEON_DIR}" ]; then - echo "โœ… PASS: peon-ping runtime directory exists at ${PEON_DIR}" -else - echo "โŒ FAIL: peon-ping runtime directory not found at ${PEON_DIR}" + echo "โŒ FAIL: /usr/local/bin/peon-ping not found or not executable" exit 1 fi -# Test 3: Check config.json exists -PEON_CONFIG="${PEON_DIR}/config.json" -if [ -f "${PEON_CONFIG}" ]; then - echo "โœ… PASS: config.json exists" -else - echo "โŒ FAIL: config.json not found at ${PEON_CONFIG}" +if ! command -v jq >/dev/null 2>&1; then + echo "โŒ FAIL: jq is not available" exit 1 fi +echo "โœ… PASS: jq is available" -# Test 4: Check that adapters directory exists -ADAPTERS_DIR="${PEON_DIR}/adapters" -if [ -d "${ADAPTERS_DIR}" ]; then - echo "โœ… PASS: adapters directory exists" -else - echo "โš ๏ธ WARN: adapters directory not found at ${ADAPTERS_DIR}" -fi +# postCreateCommand (peon-ping register) isn't guaranteed to have run when test.sh does, so run +# it here: it must be safe to run any number of times. +peon-ping register >/dev/null +peon-ping register >/dev/null -# Test 5: Check copilot adapter is available -if [ -f "${ADAPTERS_DIR}/copilot.sh" ]; then - echo "โœ… PASS: copilot adapter found" -else - echo "โš ๏ธ WARN: copilot adapter not found" -fi +SETTINGS="${HOME}/.claude/settings.json" +for event in SessionStart Stop PermissionRequest; do + count="$(jq --arg e "${event}" '[.hooks[$e][].hooks[] | select(.command | contains("peon-ping hook"))] | length' "${SETTINGS}")" + if [ "${count}" != "1" ]; then + echo "โŒ FAIL: ${event}: expected exactly one peon-ping hook entry in ${SETTINGS}, got ${count}" + exit 1 + fi +done +echo "โœ… PASS: Claude Code hooks registered once, idempotently" -# Test 6: Check peon-ping-copilot-setup helper is installed -if [ -x /usr/local/bin/peon-ping-copilot-setup ]; then - echo "โœ… PASS: peon-ping-copilot-setup helper installed" -else - echo "โš ๏ธ WARN: peon-ping-copilot-setup helper not found" +if ! grep -q "peon-ping" "${HOME}/.cursor/hooks.json" "${HOME}/.codex/config.toml"; then + echo "โŒ FAIL: Cursor and/or Codex hooks not registered" + exit 1 fi +echo "โœ… PASS: Cursor and Codex hooks registered" -# Test 7: Check that python3 is available (required dependency) -if command -v python3 >/dev/null 2>&1; then - echo "โœ… PASS: python3 is available" -else - echo "โŒ FAIL: python3 is not available" +# The hook must never fail or block the agent, even with no relay listening. +if ! echo '{"hook_event_name":"Stop","session_id":"123e4567-e89b-12d3-a456-426614174000"}' \ + | PEON_RELAY_HOST=127.0.0.1 PEON_RELAY_PORT=1 peon-ping hook claude; then + echo "โŒ FAIL: peon-ping hook exited non-zero with no relay running" exit 1 fi - -# Test 8: Check that the host.docker.internal patch script is installed -PATCH_HOSTS="/usr/local/share/peon-ping/patch-hosts.sh" -if [ -x "${PATCH_HOSTS}" ]; then - echo "โœ… PASS: patch-hosts.sh installed and executable" -else - echo "โŒ FAIL: ${PATCH_HOSTS} not found or not executable" +if [ "$(jq -r .last_active.event "${HOME}/.claude/hooks/peon-ping/.state.json")" != "Stop" ]; then + echo "โŒ FAIL: hook didn't record the event for Peon Pet" exit 1 fi +echo "โœ… PASS: hook exits 0 without a relay and records the event for Peon Pet" -# Test 9: host.docker.internal should resolve โ€” either postStartCommand already patched -# /etc/hosts by the time this test runs, or Docker Desktop already provides it. A WARN -# rather than a hard FAIL: whether the test harness actually runs postStartCommand before -# test.sh isn't guaranteed here, so this isn't proof of a real bug either way. +# Whether host.docker.internal resolves depends on whether the harness ran postStartCommand. if getent hosts host.docker.internal >/dev/null 2>&1; then echo "โœ… PASS: host.docker.internal resolves" else - echo "โš ๏ธ WARN: host.docker.internal does not resolve yet โ€” re-run ${PATCH_HOSTS} manually if the test harness doesn't trigger postStartCommand" + echo "โš ๏ธ WARN: host.docker.internal does not resolve yet โ€” peon-ping check runs on postStartCommand" fi echo "" diff --git a/test/peon-ping/with_claude_dev.sh b/test/peon-ping/with_claude_dev.sh index 03d873d..bdedc2d 100755 --- a/test/peon-ping/with_claude_dev.sh +++ b/test/peon-ping/with_claude_dev.sh @@ -4,14 +4,11 @@ # Copyright (C) 2025 baxyz # SPDX-License-Identifier: LGPL-3.0-or-later # -# Exercises peon-ping alongside claude-dev: both install via postCreateCommand (not at image -# build time, when no volume is mounted yet), ordered by installsAfter so claude-dev's own -# postCreateCommand (which links ~/.claude to its persistent volume) runs first. By the time -# peon-ping's postCreateCommand runs, ~/.claude is already the real, volume-backed directory, -# so peon-ping just installs straight into it โ€” nothing claude-dev-specific to verify beyond -# that install actually landing in the right (already-linked) place. Plain test.sh (installed -# alone, without claude-dev) only exercises that same install against an ordinary directory -# and can't tell the two cases apart. +# Exercises peon-ping alongside claude-dev: both register through postCreateCommand (not at +# image build time, when no volume is mounted yet), ordered by installsAfter so claude-dev's +# postCreateCommand (which links ~/.claude to its persistent volume) runs first. The hooks must +# therefore land in the volume-backed settings.json โ€” plain test.sh (no claude-dev) can't tell +# that case from an ordinary ~/.claude directory. set -e @@ -33,44 +30,15 @@ if [ ! -L "${CLAUDE_DIR}" ]; then fi echo "โœ… PASS: ${CLAUDE_DIR} is claude-dev's symlinked volume" -PEON_DIR="${CLAUDE_DIR}/hooks/peon-ping" -if [ ! -d "${PEON_DIR}" ]; then - echo "โŒ FAIL: ${PEON_DIR} not found โ€” peon-ping's postCreateCommand didn't install into the real ~/.claude" - exit 1 -fi -echo "โœ… PASS: peon-ping installed directly into the volume-backed ${PEON_DIR}" - -PEON_BIN="${TARGET_HOME}/.local/bin/peon" -if [ ! -x "${PEON_BIN}" ]; then - echo "โŒ FAIL: ${PEON_BIN} missing or not executable" - exit 1 -fi -if [ ! -e "$(readlink -f "${PEON_BIN}")" ]; then - echo "โŒ FAIL: ${PEON_BIN} is a dead symlink โ€” this is the exact bug being regression-tested" - exit 1 -fi -echo "โœ… PASS: ${PEON_BIN} resolves to a real file" - SETTINGS="${CLAUDE_DIR}/settings.json" if [ ! -f "${SETTINGS}" ]; then - echo "โŒ FAIL: ${SETTINGS} not found" + echo "โŒ FAIL: ${SETTINGS} not found โ€” peon-ping register didn't run into the real ~/.claude" exit 1 fi -if ! command -v python3 >/dev/null 2>&1 || ! python3 -c " -import json, sys -with open('${SETTINGS}') as f: - hooks = json.load(f).get('hooks', {}) -found = any( - 'hooks/peon-ping/' in h.get('command', '') - for entries in hooks.values() - for entry in entries - for h in entry.get('hooks', []) -) -sys.exit(0 if found else 1) -"; then - echo "โŒ FAIL: no peon-ping command found in ${SETTINGS}'s hooks" +if ! jq -e '[.hooks[][].hooks[] | select(.command | contains("peon-ping hook"))] | length > 0' "${SETTINGS}" >/dev/null; then + echo "โŒ FAIL: no peon-ping hook command found in ${SETTINGS}'s hooks" exit 1 fi -echo "โœ… PASS: peon-ping hook entries present in the real ${SETTINGS}" +echo "โœ… PASS: peon-ping hook entries present in the volume-backed ${SETTINGS}" echo "๐ŸŽ‰ Test passed."