diff --git a/README.md b/README.md index 1baa5aa..75a7c0e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ Cross-agent messaging for CLI AI agents. No daemon, no network, no complexity. You stop being the copy-paste courier between your agents. Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, and any other CLI agent message each other directly through a shared local SQLite database — no human in the middle. +

+ Supported agents: Claude Code, Codex, Gemini, GitHub Copilot, Antigravity, OpenCode, Hermes +

+ **What it isn't:** - Not MCP. No MCP server, no extra runtime — just `bash` + `sqlite3`. @@ -36,11 +41,8 @@ In real use it looks like this — Claude Code asking Codex for a code review an **Requires:** `bash` and `sqlite3`. macOS ships both. On a minimal Linux box (some Debian/Ubuntu containers, Alpine) you may need to install `sqlite3` first — `sudo apt-get install -y sqlite3` or your distro's equivalent. ```bash -# 1. Install (one-liner) -bash <(curl -fsSL https://raw.githubusercontent.com/fujibee/agmsg/main/setup.sh) - -# Or clone first if you want to inspect the code -git clone https://github.com/fujibee/agmsg.git && cd agmsg && ./install.sh +# 1. Install — npx is the fastest path, no clone needed +npx agmsg # 2. Restart Claude Code / Codex / Gemini CLI / Antigravity / OpenCode to pick up the new skill @@ -54,7 +56,7 @@ git clone https://github.com/fujibee/agmsg.git && cd agmsg && ./install.sh That's it. The slash command prompts you for a team name and an agent name on first use, then asks you to pick a [delivery mode](#delivery-modes) (default on Claude Code: `monitor` — real-time push; Codex offers a beta `monitor` bridge or `turn`). After that, you talk to your agent naturally — see [First run](#first-run) below. -Prefer a different install method? See [Install](#install) below for `npm` / `npx` and the Claude Code plugin marketplace paths. +Prefer to inspect the code first, track the latest `main`, or pick a custom command name? See [Install](#install) below for the `setup.sh` one-liner, `git clone`, and the Claude Code plugin marketplace paths. ## How it works @@ -272,7 +274,7 @@ Codex supports `mode monitor` as a **beta** app-server bridge, plus `mode turn` > ⚠️ **The monitor beta changes how Codex starts — opt in only if you understand it.** Codex has no Monitor tool, so `mode monitor` installs a shim at `~/.agents/bin/codex` and asks you to put `~/.agents/bin` **first on your PATH**, so `codex` then resolves to the shim instead of the real binary. In monitor-mode projects the shim routes interactive launches through a bridge that turns incoming agmsg messages into turns on the current Codex thread; `codex exec` and non-monitor projects pass straight through to the real Codex. It depends on experimental Codex app-server behavior and has known rough edges (orphans on TUI close — #149; one identity per project — #150). -If the shim can't be installed, launch with `~/.agents/skills//scripts/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup, PATH notes, and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). +If the shim can't be installed, launch with `~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh`. Codex sandboxing must allow writes to the skill's `db/`, `teams/`, and `run/` dirs — `install.sh` configures those `writable_roots` when `~/.codex/config.toml` exists. Setup, PATH notes, and internals: [docs/codex-monitor-beta.md](docs/codex-monitor-beta.md). ### GitHub Copilot CLI @@ -309,8 +311,6 @@ See [docs/opencode.md](docs/opencode.md) for full setup instructions. `send.sh` takes exactly four positional arguments: ` ""`. Quote the message so the shell sees it as one argument; an unquoted message with spaces will be misparsed. -`hook.sh on|off` still works as a legacy alias for `delivery.sh set turn|off` but prints a deprecation notice. - ## FAQ / Design notes **Is this MCP? Do I need an MCP server?** @@ -380,6 +380,7 @@ Auto-detects installed skill directories and cleans up: skill files, slash comma | Variable | Default | Purpose | |---|---|---| | `AGMSG_STORAGE_PATH` | `/db` | Directory holding the SQLite message store (`messages.db`). Override to relocate the store — handy for tests, sandboxes, or running isolated instances. | +| `AGMSG_PLUGIN_DIRS` | (unset) | `:`-separated extra directories to search for external drivers, in addition to `/plugins`. Each holds `//` subdirs. Drivers found here are still ignored until opted into with `agmsg plugin trust`. See [docs/plugins.md](docs/plugins.md). | The message store path resolves as **`AGMSG_STORAGE_PATH` (env) > built-in default**. (A config-file layer is planned to slot in between the two as part of the storage-driver work; the intended order is env > config > default.) The override is scoped to the SQLite store only — team configs under `teams/` are unaffected. @@ -480,8 +481,10 @@ bats tests/ # requires bats-core: brew install bats-core ├── SKILL.md # Skill definition (read by CC & Codex) ├── agents/ │ └── openai.yaml # Codex metadata -├── scripts/ # Bash scripts -├── templates/ # Command templates per tool +├── scripts/ # Bash scripts (the type-agnostic engine) +│ ├── lib/ # Sourced helper libraries +│ └── drivers/types// # Built-in agent-type drivers (manifest + runtime) +├── plugins/// # External drivers you opt into (agmsg plugin trust) ├── db/messages.db # SQLite WAL-mode message store └── teams/ # Team configs (self-contained) └── / @@ -495,6 +498,22 @@ bats tests/ # requires bats-core: brew install bats-core - **No daemon**: Direct filesystem access - **No network**: Everything local +## Plugins + +agmsg's pluggable units are **drivers** grouped by axis (`types` for agent +runtimes; `storage` and `delivery` to follow). Built-ins ship under +`scripts/drivers/`; you can drop your own under `/plugins///` +(or point `AGMSG_PLUGIN_DIRS` at a directory) to extend agmsg without forking. + +Because a driver is shell code that runs with your privileges, **external drivers +are never loaded until you opt in** — an unexpected drop-in is ignored (with a +warning) until you run `agmsg plugin trust /`. List what's discovered +and its trust state with `agmsg plugin list`. + +Full discovery order, the trust model, and authoring guidance: +[docs/plugins.md](docs/plugins.md) (design rationale in +[ADR 0002](docs/adr/0002-driver-discovery-and-plugin-opt-in.md)). + ## Community - **Product Hunt**: #5 Product of the Day, [2026-06-09 launch](https://www.producthunt.com/products/agmsg) — 219 upvotes, 39 comments diff --git a/SKILL.md b/SKILL.md index 66a5d5d..1137b2c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -83,8 +83,7 @@ Do NOT manually edit config files. Always use join.sh. # this session held on so peers can pick them up immediately. ~/.agents/skills/agmsg/scripts/reset.sh "$(pwd)" [agent_id] [session_id] -# Set delivery mode for this project. Replaces the legacy hook.sh on/off, -# which is kept as a deprecated alias only. +# Set delivery mode for this project. # monitor — real-time push via SessionStart + Monitor tool (claude-code only) # turn — Stop-hook pulls at the end of each assistant turn # both — monitor primary, turn as fallback diff --git a/docs/adr/0002-driver-discovery-and-plugin-opt-in.md b/docs/adr/0002-driver-discovery-and-plugin-opt-in.md new file mode 100644 index 0000000..afe3429 --- /dev/null +++ b/docs/adr/0002-driver-discovery-and-plugin-opt-in.md @@ -0,0 +1,95 @@ +# ADR 0002: Driver discovery, external plugin location, and opt-in trust + +**Status:** accepted +**Date:** 2026-06-21 +**Deciders:** @fujibee + +## Context + +[ADR 0001](0001-storage-driver-pluginization.md) established the 3-axis driver +model (storage, agent, delivery) and deliberately **deferred the plugin loader** +— the machinery that discovers drivers shipped outside agmsg core — until a +concrete external driver was wanted. The 1.1.0 restructure brings the agent-type +("types") axis fully into the driver layout, and that work needs a discovery +story now: where bundled drivers live, where external ones live, and how an +external driver — which is shell code run with the user's privileges — is allowed +to load without becoming a drive-by code-execution vector. This ADR makes those +decisions for all axes; it supersedes ADR 0001's deferred-loader note and its +tentative `~/.agents/agmsg/plugins/` path. + +## Decision + +**Bundled drivers live in-tree at `scripts/drivers//`** (the types +axis uses a directory with a `type.conf` manifest; file-based axes may use +`.sh`). The agent-type tree moved from the repo root to +`scripts/drivers/types/` accordingly. + +**External drivers are discovered from, in priority order:** `scripts/drivers` +(built-in), then `/plugins`, then each `:`-separated entry of +`$AGMSG_PLUGIN_DIRS`. Each base holds axis subdirs (`//`). +Among *eligible* candidates, **later bases override earlier ones**, so an opted-in +plugin can shadow a built-in. + +**External drivers are never loaded unless explicitly opted into.** A built-in +(`scripts/drivers`) is always trusted; anything under `plugins/` or +`$AGMSG_PLUGIN_DIRS` is ignored — with a clear stderr warning — until the user +runs `agmsg plugin trust /`. Trust is **path-pinned**: the allowlist +records `/` → absolute path, and a trusted name resolved at a +different path is not honored (a directory swap under a trusted name does not +silently activate new code). The allowlist is a TSV at `/db/ +trusted-plugins` (preserved across `--update`, like `config.yaml`). + +The opt-in CLI is `agmsg plugin list | trust | untrust `, where +`` is `/` or a bare `` that must be unambiguous across +axes. `driver-registry.sh` provides the axis-generic bases + trust policy; +`type-registry.sh` is the types-axis facade built on it. + +`plugin.json` metadata and `min_core_version` gating (ADR 0001 §5) remain +deferred — bundled and opted-in drop-in drivers are enough for now. + +## Alternatives considered + +- **Auto-load any driver found on the search path (no opt-in).** Rejected: a + malicious or accidental drop-in under `plugins/` would execute with the user's + privileges on the next agmsg invocation. The whole point of a discovery path is + undermined if discovery == execution. +- **Opt-in only when a plugin *overrides a built-in*; auto-load brand-new + types.** Rejected: a brand-new external type is still arbitrary shell code. "I + didn't put this here" is exactly the attack to defend against, override or not. +- **Trust by name only (not path).** Rejected: trusting `types/foo` once would + then honor any future `types/foo`, so swapping the directory contents (or + shadowing via a higher-priority base) silently activates unreviewed code. +- **External plugins at `~/.agents/agmsg/plugins/` (ADR 0001's tentative path).** + Rejected in favor of `/plugins`: it sits beside the install the + plugin extends, the `cp -R` installer never deletes it (survives `--update`), + and per-command-name installs get their own plugin set. `~/.agents/agmsg/` + remains the runtime/config dir (run/, config.yaml). +- **Store the allowlist in `config.yaml`.** Rejected: driver identities contain + `/` (`types/codex`), which the YAML key parser does not handle; a flat TSV is + simpler to append/grep/remove and sidesteps the escaping. +- **In-tree-wins (built-ins reserved, no override).** Rejected: it blocks the + legitimate "customize a built-in type locally" use case. Later-wins among + *trusted* candidates allows it without weakening the trust boundary. + +## Consequences + +- Positive: dropping a directory under `plugins/` (or pointing `AGMSG_PLUGIN_DIRS` + at one) extends agmsg with no fork; the opt-in step keeps that from being an + execution vector. The same machinery serves the storage and delivery axes. +- Positive: built-ins always work with zero config; the trust prompt only appears + when an external driver is actually present. +- Negative: a new user-facing concept (`agmsg plugin trust`) and a small amount + of registry complexity (per-axis trust gating on every resolution). +- Negative: path-pinned trust means moving a trusted plugin's directory requires + re-trusting it. This is intentional friction. +- Neutral: `AGMSG_TYPES_ROOT` (a test-only in-tree override) is removed; the + registry always resolves its root from the lib's own location. `AGMSG_HOME` / + `~/.config/agmsg/types` external discovery is replaced by the scheme above. + +## References + +- Supersedes the deferred-loader note and `~/.agents/agmsg/plugins/` path in + [ADR 0001](0001-storage-driver-pluginization.md) +- Specification: [`docs/spec/driver-interface.md`](../spec/driver-interface.md) +- Implements: `scripts/lib/driver-registry.sh`, `scripts/lib/type-registry.sh`, + `scripts/plugin.sh` diff --git a/docs/agent-types.md b/docs/agent-types.md new file mode 100644 index 0000000..b3fca42 --- /dev/null +++ b/docs/agent-types.md @@ -0,0 +1,117 @@ +# Agent types + +agmsg supports several agent runtimes — claude-code, codex, gemini, antigravity, +copilot, opencode — and each is described by a small **manifest** so that the rest +of agmsg (detection, the join whitelist, spawn, and delivery routing) discovers it +from data instead of hardcoded `case` arms. + +Adding a type is a **manifest + a command template** (plus an optional +`_delivery.sh` plug for bespoke delivery), not an edit across `whoami.sh`, +`join.sh`, `spawn.sh`, and `delivery.sh`. + +## The manifest + +Each type has `scripts/drivers/types//type.conf` — read-only `key=value` +**data**. agmsg reads it with a small per-key reader; it is **never `source`d**, so +a manifest cannot execute code. Multi-value keys are whitespace-separated. + +| key | required | meaning | +|---|---|---| +| `name` | yes | the type name (matches the directory) | +| `template` | yes | the `/agmsg` command template filename, relative to the type dir (e.g. `template.md`); becomes `SKILL.md` | +| `detect` | — | env-var names whose presence selects this type. `explicit` = never auto-detected from the environment | +| `detect_proc` | — | parent-process-name glob patterns that select this type (e.g. `codex codex-*`) | +| `cli` | spawnable types | the launch binary | +| `spawnable` | — | `yes` if `spawn.sh` can launch this type | +| `spawn` | — | a `.mjs` node-launcher (beside the manifest) `spawn.sh` runs via Node; also marks the type spawnable | +| `hooks_file` | yes | project-relative delivery hooks file (e.g. `.codex/hooks.json`) | +| `monitor` | — | `yes` if the type exposes a native Monitor tool; `spawn` skips the readiness wait when `no` | +| `delivery_modes` | — | space-separated delivery modes the type's CLI accepts (e.g. `monitor turn off`); `delivery.sh`'s gate rejects anything else. Defaults to `monitor turn both off` when omitted | +| `stop_output` | — | output protocol for the Stop/turn inbox check — `json` (codex, copilot) vs. plain text (default) | +| `hook_windows_wrap` | — | `yes` if JSON hook entries also need a Windows-native `commandWindows` variant (codex) | + +> The reader does not fail-fast: an omitted key reads as the empty string, so +> "required" above means "needed for the type to actually work", not "validated at +> load time". + +### Detection + +`whoami.sh` auto-detects the running type when none is passed: + +1. **Environment** — the manifests' `detect=` env vars, evaluated in sorted type + order, which preserves the precedence claude-code < codex < gemini (a runtime's + own session vars beat the `GEMINI_*` family that users also set for the SDK). + `detect=explicit` types are never selected here. +2. **Process tree** — walking up from the current process, the first type whose + `detect_proc=` glob matches the ancestor's name wins. +3. Falls back to `claude-code`. + +> Precedence note: env detection iterates types in **sorted name order**, so when +> two types' `detect=` vars are both present the alphabetically-earlier type wins. +> Keep `detect=` vars runtime-exclusive (a runtime's own session var, not a shared +> SDK var) so ties don't arise; this is why the `GEMINI_*` family — which users +> set without the CLI — sits behind the claude-code/codex session vars. + +### Delivery + +`hooks_file=` is the per-project file delivery hooks are written into, and +`delivery_modes=` declares which modes the type accepts (the central gate in +`delivery.sh` rejects the rest). The hook *format* lives in a **Template Method**: +`delivery.sh` defines the default behavior (JSON event-hooks) and a type's optional +`scripts/drivers/types//_delivery.sh` plug overrides any of +`agmsg_delivery_apply` / `on_enable` / `on_disable` / `status`. Rule-file types +(gemini, antigravity, …) delegate to the shared `rulefile_apply`; codex's plug adds +its bridge/shim lifecycle. No per-type `case` arms remain in `delivery.sh`. + +### Node-launcher types (external add-ons) + +A type whose manifest sets a `spawn=` key to a `.mjs` file is launched by +`spawn.sh` **via Node** rather than through a `cli=` binary. agmsg core invokes the +launcher with only four **universal** flags: + +``` +node /.mjs --name --team --project --initial-input +``` + +All type-specific configuration (which binary, which model, which transport, env +vars) is the launcher's **own default / environment** — agmsg core never names any +add-on. This is what lets a node-launcher type ship entirely outside the agmsg tree +as an external plugin (under `/plugins/types//` or a dir on +`$AGMSG_PLUGIN_DIRS`) with no built-in edits. External types must be opted into +with `agmsg plugin trust types/` — see +[ADR 0002](adr/0002-driver-discovery-and-plugin-opt-in.md). + +## Adding a type + +1. Create `scripts/drivers/types//type.conf` with at least `name`, + `template`, and `hooks_file` (add `detect`/`detect_proc` for auto-detection, + `cli` + `spawnable=yes` if `spawn.sh` should launch it, and `delivery_modes` to + restrict the modes the type accepts). +2. Add the command template beside the manifest as `template.md` (the path the + `template=` key names, relative to the type dir). +3. If the type needs a delivery behavior that doesn't exist yet, add a + `_delivery.sh` plug in the type dir overriding `agmsg_delivery_apply` / + `on_enable` / `on_disable` / `status`. Reusing an existing format (default JSON + hooks, or `rulefile_apply`) needs no code. + +That's it — `whoami.sh`, `join.sh`, and `spawn.sh` pick the type up from the +registry with no further edits. + +## Worked example + +The six built-in manifests under `scripts/drivers/types/` are the reference. For +instance `scripts/drivers/types/codex/type.conf`: + +``` +name=codex +template=template.md +cli=codex +spawnable=yes +detect=CODEX_SANDBOX CODEX_THREAD_ID +detect_proc=codex codex-* +hooks_file=.codex/hooks.json +monitor=no +stop_output=json +hook_windows_wrap=yes +delivery_modes=monitor turn off +``` diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index cd7bfc4..2980f6c 100644 --- a/docs/codex-monitor-beta.md +++ b/docs/codex-monitor-beta.md @@ -68,13 +68,13 @@ it untouched. You can either move that command aside and run `mode monitor` again, or launch monitor sessions explicitly: ```bash -~/.agents/skills/agmsg/scripts/codex-monitor.sh +~/.agents/skills/agmsg/scripts/drivers/types/codex/codex-monitor.sh ``` For custom command names, replace `agmsg` with the installed skill name: ```bash -~/.agents/skills//scripts/codex-monitor.sh +~/.agents/skills//scripts/drivers/types/codex/codex-monitor.sh ``` ## What The Shim Does @@ -259,6 +259,6 @@ For an unattended worker, layer these on top of the gate: ## Related Details - [Delivery modes](../README.md#delivery-modes) -- [Codex bridge implementation](../scripts/codex-bridge.js) -- [Monitor launcher](../scripts/codex-monitor.sh) -- [Codex shim](../scripts/codex-shim.sh) +- [Codex bridge implementation](../scripts/drivers/types/codex/codex-bridge.js) +- [Monitor launcher](../scripts/drivers/types/codex/codex-monitor.sh) +- [Codex shim](../scripts/drivers/types/codex/codex-shim.sh) diff --git a/docs/design.md b/docs/design.md index d432037..953a61d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -127,7 +127,7 @@ must track the same resolved project); direct shell invocations and | Script | Purpose | |---|---| -| `init-db.sh` | Create SQLite database with schema | +| `internal/init-db.sh` | Create SQLite database with schema | | `send.sh` | Insert a message into the database | | `inbox.sh` | Show unread messages and mark as read | | `history.sh` | Show message history (newest first, displayed oldest first) | @@ -136,7 +136,6 @@ must track the same resolved project); direct shell invocations and | `team.sh` | List team members | | `whoami.sh` | Identify agent by project path and type | | `rename.sh` | Rename agent in config and message history | -| `hook.sh` | Enable/disable Stop hook (on/off) | | `check-inbox.sh` | Hook entry point — cooldown, check, notify | | `config.sh` | Read/write user config (YAML) | diff --git a/docs/logos/antigravity.png b/docs/logos/antigravity.png new file mode 100644 index 0000000..9179031 Binary files /dev/null and b/docs/logos/antigravity.png differ diff --git a/docs/logos/claude.svg b/docs/logos/claude.svg new file mode 100644 index 0000000..2cf56e9 --- /dev/null +++ b/docs/logos/claude.svg @@ -0,0 +1 @@ +Claude Code diff --git a/docs/logos/codex.svg b/docs/logos/codex.svg new file mode 100644 index 0000000..4c648af --- /dev/null +++ b/docs/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/docs/logos/copilot.svg b/docs/logos/copilot.svg new file mode 100644 index 0000000..8716d6f --- /dev/null +++ b/docs/logos/copilot.svg @@ -0,0 +1 @@ +GitHub Copilot \ No newline at end of file diff --git a/docs/logos/gemini.svg b/docs/logos/gemini.svg new file mode 100644 index 0000000..1111255 --- /dev/null +++ b/docs/logos/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/docs/logos/hermes.png b/docs/logos/hermes.png new file mode 100644 index 0000000..2c4a160 Binary files /dev/null and b/docs/logos/hermes.png differ diff --git a/docs/logos/opencode.svg b/docs/logos/opencode.svg new file mode 100644 index 0000000..987d393 --- /dev/null +++ b/docs/logos/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logos/supported-agents.png b/docs/logos/supported-agents.png new file mode 100644 index 0000000..d5c1f8a Binary files /dev/null and b/docs/logos/supported-agents.png differ diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..ea47e92 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,121 @@ +# Plugins (external drivers) + +agmsg's pluggable units are **drivers**, grouped by **axis**: + +| axis | what it swaps | status | +|---|---|---| +| `types` | agent runtimes (claude-code, codex, gemini, …) | shipping | +| `storage` | the message store (sqlite, …) | planned | +| `delivery` | how messages reach an agent (monitor / turn / …) | planned | + +Built-in drivers ship in-tree under `scripts/drivers//`. A **plugin** +is a driver shipped *outside* core that you drop in — no fork, no patch. This doc +covers discovering, trusting, and authoring them. The design rationale lives in +[ADR 0002](adr/0002-driver-discovery-and-plugin-opt-in.md); the driver contract +in [docs/spec/driver-interface.md](spec/driver-interface.md). + +## Where plugins live + +agmsg searches these bases, in order: + +1. `/scripts/drivers/` — built-ins (always trusted) +2. `/plugins/` — your default plugin drop-in dir +3. each `:`-separated entry of `$AGMSG_PLUGIN_DIRS` — extra dirs + +`` is the install dir (`~/.agents/skills//`). Each base holds +axis subdirs, so a types plugin named `foo` lives at +`/plugins/types/foo/` (with the same layout as a built-in type — see +[Agent types](agent-types.md)). + +Among **eligible** candidates of the same `/`, **later bases win**, so +a trusted plugin can deliberately override a built-in (e.g. customize `codex` +locally). + +## Trust: external drivers are opt-in + +A driver is shell code that runs with your privileges. So agmsg **never loads an +external driver until you explicitly opt in** — this turns a stray or malicious +drop-in from a code-execution vector into a harmless, ignored directory. + +- Built-ins (`scripts/drivers/`) are always trusted. +- Anything under `plugins/` or `$AGMSG_PLUGIN_DIRS` is **ignored until trusted** — + with a one-line warning on stderr the first time the registry runs: + + ``` + agmsg: external plugin 'types/foo' found at /…/plugins/types/foo but not trusted (ignored). + Opt in if you put it there intentionally: agmsg plugin trust types/foo + ``` + +- Trust is **path-pinned**: the allowlist records `/` → the absolute + path you trusted. A driver of that name resolved at a *different* path is **not** + honored, so swapping a directory's contents (or shadowing it from a + higher-priority base) does not silently activate unreviewed code. Moving a + trusted plugin therefore requires re-trusting it — intentional friction. + +The allowlist is a plain TSV at `/db/trusted-plugins` (preserved across +`--update`, like `config.yaml`). You normally manage it with the CLI below. + +## The `agmsg plugin` command + +``` +agmsg plugin list # every discovered driver + its trust state +agmsg plugin trust # opt into an external driver +agmsg plugin untrust # revoke +``` + +`` is `/` (e.g. `types/codex`) or a bare ``. A bare name +matches across axes; if more than one axis has it, you must qualify it: + +``` +$ agmsg plugin trust codex +agmsg plugin: 'codex' is ambiguous across axes: + types/codex + storage/codex + qualify it, e.g. agmsg plugin trust types/codex +``` + +`agmsg plugin list` marks each driver `builtin`, `trusted`, or `UNTRUSTED`: + +``` +AXIS/NAME STATE PATH +types/codex builtin /…/scripts/drivers/types/codex +types/foo trusted /…/plugins/types/foo +types/bar UNTRUSTED /…/plugins/types/bar +``` + +> On non-Windows hosts the `agmsg` command surface is provided by your agent's +> skill flow; you can always invoke the script directly: +> `~/.agents/skills//scripts/plugin.sh list`. + +## Authoring a types plugin + +A types plugin is exactly a built-in type, placed under `plugins/types//` +instead of `scripts/drivers/types/`. The minimum is a `type.conf` manifest plus a +`template.md`; add a `_delivery.sh` plug for bespoke delivery. The full manifest +key reference and the delivery Template Method are in +[Agent types](agent-types.md). + +``` +/plugins/types/foo/ +├── type.conf # name=foo, template=template.md, hooks_file=…, delivery_modes=… +├── template.md # the /agmsg command template (becomes SKILL.md) +└── _delivery.sh # optional: override agmsg_delivery_apply / on_enable / … +``` + +Then trust it and confirm: + +``` +agmsg plugin trust types/foo +agmsg plugin list # types/foo -> trusted +``` + +Manifests are read as **data** (never `source`d), so a plugin's `type.conf` +cannot execute code; only its `_delivery.sh` / launcher scripts run, and only +once you've trusted the plugin. + +## Not yet supported + +`plugin.json` metadata, `min_core_version` gating, and signing/sandboxing are +deferred (see [ADR 0001](adr/0001-storage-driver-pluginization.md) §5 and +[ADR 0002](adr/0002-driver-discovery-and-plugin-opt-in.md)). Today a plugin is a +plain directory you trust by path. diff --git a/docs/spec/driver-interface.md b/docs/spec/driver-interface.md index 34058c9..ad2c84a 100644 --- a/docs/spec/driver-interface.md +++ b/docs/spec/driver-interface.md @@ -13,7 +13,9 @@ These conventions apply to every driver on every axis. ### 1.1 Driver location -Bundled drivers live at `scripts/drivers//.sh`. Their metadata is implicit and tied to the agmsg core version. +Bundled drivers live at `scripts/drivers//`. File-based axes use a single `.sh`; the agent-type ("types") axis uses a directory `scripts/drivers/types//` holding a `type.conf` manifest plus the type's runtime. Their metadata is implicit and tied to the agmsg core version. + +External (non-bundled) drivers are discovered from `/plugins//` and from `$AGMSG_PLUGIN_DIRS`, and must be opted into — see [ADR 0002](../adr/0002-driver-discovery-and-plugin-opt-in.md). ### 1.2 Calling convention @@ -149,7 +151,7 @@ Active driver per axis is recorded in `~/.agents/agmsg/config.json`: ## 5. Out of scope (deferred) -- **Plugin loader** — `~/.agents/agmsg/plugins///` discovery, `plugin.json` parsing, `min_core_version` gating, and the `incompatible_core` status code. Deferred until a concrete third-party driver is wanted; in the meantime, all drivers ship bundled. +- **Plugin loader** — external-driver discovery (`/plugins/`, `$AGMSG_PLUGIN_DIRS`) and the opt-in trust model are now defined by [ADR 0002](../adr/0002-driver-discovery-and-plugin-opt-in.md). Still deferred from that loader: `plugin.json` metadata parsing, `min_core_version` gating, and the `incompatible_core` status code. - **Plugin signing or sandboxing** — orthogonal to the loader; would be addressed when the loader lands. - **Per-project active driver override** — v1 is machine-wide; future enhancement. - **Subcommand + JSONL-pipe driver protocol** (language-independent drivers) — deferred until a non-bash driver is actually wanted. diff --git a/install.sh b/install.sh index e1a4c17..f2cb1f2 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" AGENTS_DIR="$HOME/.agents" +# Type registry — resolve each type's SKILL command template from its manifest +# (scripts/drivers/types//template.md) instead of a hardcoded templates/ path. Read-only +# helpers; safe to source. +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/scripts/lib/type-registry.sh" + # Resolve a provenance version for the source being installed, so an installed # copy is uniquely identifiable even between tagged releases (the canonical # VERSION only bumps at release). From a git checkout: `git describe` — tag + @@ -165,7 +171,7 @@ while [[ $# -gt 0 ]]; do echo "Options:" echo " --cmd Command & skill folder name (default: agmsg)" echo " Claude Code: /, Codex/Gemini/Antigravity: \$" - echo " --agent-type Agent type: claude-code, codex, gemini, antigravity, opencode" + echo " --agent-type Agent type: claude-code, codex, gemini, antigravity, opencode, hermes" echo " Selects which template becomes SKILL.md (matches the" echo " arg passed to join.sh / whoami.sh)" echo " --update Update skill scripts only (preserve DB and teams)" @@ -239,24 +245,27 @@ if [ "$UPDATE_ONLY" = true ]; then AGENT_TYPE="codex" fi fi - SKILL_TEMPLATE="cmd.codex.md" - if [ "$AGENT_TYPE" = "gemini" ]; then - SKILL_TEMPLATE="cmd.gemini.md" - elif [ "$AGENT_TYPE" = "antigravity" ]; then - SKILL_TEMPLATE="cmd.antigravity.md" - elif [ "$AGENT_TYPE" = "opencode" ]; then - SKILL_TEMPLATE="cmd.opencode.md" - fi - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$SCRIPT_DIR/templates/$SKILL_TEMPLATE" > "$SKILL_DIR/SKILL.md" - # Recursive copy so nested helper dirs (scripts/lib/) ship without enumerating files. + # The shared SKILL.md uses the codex template by default; gemini/antigravity/ + # opencode get their own. (claude-code and copilot reuse the codex-typed + # shared SKILL.md; their dedicated copies are dropped separately below.) + TPL_TYPE="codex" + case "$AGENT_TYPE" in + gemini|antigravity|opencode|hermes) TPL_TYPE="$AGENT_TYPE" ;; + esac + sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$(agmsg_type_template_path "$TPL_TYPE")" > "$SKILL_DIR/SKILL.md" + # Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) + # ship without enumerating files. The agent-type manifests and per-type runtimes + # live under scripts/drivers/types/ now, so this single copy carries them too. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" - for tmpl in "$SCRIPT_DIR/templates/"cmd.*.md; do - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$tmpl" > "$SKILL_DIR/templates/$(basename "$tmpl")" - done + # Ship the external-plugin drop-in dir (just its README) so the location exists + # post-install. A plain cp — not cp -R --delete — preserves any plugins the + # user dropped in and their db/trusted-plugins opt-ins. + mkdir -p "$SKILL_DIR/plugins" + cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true # Refresh the Claude Code slash command file (was missed in earlier --update flows). CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$CC_COMMANDS_DIR" ] && [ -f "$CC_COMMANDS_DIR/$SKILL_NAME.md" ]; then - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$SCRIPT_DIR/templates/cmd.claude-code.md" > "$CC_COMMANDS_DIR/$SKILL_NAME.md" + sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$(agmsg_type_template_path claude-code)" > "$CC_COMMANDS_DIR/$SKILL_NAME.md" fi # Refresh / install the Copilot CLI skill (Copilot reads SKILL.md from its # own skills dir; the shared ~/.agents/skills//SKILL.md is @@ -266,16 +275,34 @@ if [ "$UPDATE_ONLY" = true ]; then COPILOT_SKILL_DIR="$HOME/.copilot/skills/$SKILL_NAME" if [ -d "$HOME/.copilot" ]; then mkdir -p "$COPILOT_SKILL_DIR" - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$SCRIPT_DIR/templates/cmd.copilot.md" > "$COPILOT_SKILL_DIR/SKILL.md" + sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$(agmsg_type_template_path copilot)" > "$COPILOT_SKILL_DIR/SKILL.md" fi # Refresh / install the OpenCode skill (same reasoning as Copilot above). OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$SKILL_NAME" if [ -d "$HOME/.config/opencode" ]; then mkdir -p "$OPENCODE_SKILL_DIR" - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$SCRIPT_DIR/templates/cmd.opencode.md" > "$OPENCODE_SKILL_DIR/SKILL.md" + sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$(agmsg_type_template_path opencode)" > "$OPENCODE_SKILL_DIR/SKILL.md" + fi + # Refresh / install the Hermes Agent skill (same reasoning as Copilot above). + HERMES_SKILL_DIR="$HOME/.hermes/skills/$SKILL_NAME" + if [ -d "$HOME/.hermes" ]; then + mkdir -p "$HERMES_SKILL_DIR" + sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$(agmsg_type_template_path hermes)" > "$HERMES_SKILL_DIR/SKILL.md" fi cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh + chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true + # Refresh the Codex monitor shim (~/.agents/bin/codex) if it's ours. --update + # cp's the new codex-shim-install.sh but does not re-run it, so a shim from an + # older install keeps its stale baked exec path after the + # types/ -> scripts/drivers/types/ move. Re-running install regenerates it with + # the new path; install is idempotent and overwrites only an agmsg shim (a + # user's own codex binary fails is_agmsg_shim and is left untouched). + CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" + if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then + AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ + && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" + fi install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" @@ -306,28 +333,36 @@ SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" # --- Install skill --- echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." -mkdir -p "$SKILL_DIR"/{scripts,templates,db,agents} - -# SKILL.md is generated from the agent-specific command template. -SKILL_TEMPLATE="cmd.codex.md" -if [ "$AGENT_TYPE" = "gemini" ]; then - SKILL_TEMPLATE="cmd.gemini.md" -elif [ "$AGENT_TYPE" = "antigravity" ]; then - SKILL_TEMPLATE="cmd.antigravity.md" -elif [ "$AGENT_TYPE" = "opencode" ]; then - SKILL_TEMPLATE="cmd.opencode.md" -fi -sed "s/__SKILL_NAME__/$CMD_NAME/g" "$SCRIPT_DIR/templates/$SKILL_TEMPLATE" > "$SKILL_DIR/SKILL.md" -# Recursive copy so nested helper dirs (scripts/lib/) ship without enumerating files. +mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} + +# SKILL.md is generated from the agent-specific command template, resolved from +# the type manifest (scripts/drivers/types//template.md). The shared SKILL.md uses the +# codex template by default; gemini/antigravity/opencode get their own. +TPL_TYPE="codex" +case "$AGENT_TYPE" in + gemini|antigravity|opencode|hermes) TPL_TYPE="$AGENT_TYPE" ;; +esac +sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path "$TPL_TYPE")" > "$SKILL_DIR/SKILL.md" +# Recursive copy so nested helper dirs (scripts/lib/, scripts/drivers/types/) ship +# without enumerating files. The agent-type manifests and per-type runtimes live +# under scripts/drivers/types/ now, so this single copy carries them too. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" - -# Replace placeholder in templates with actual skill name -for tmpl in "$SCRIPT_DIR/templates/"cmd.*.md; do - sed "s/__SKILL_NAME__/$CMD_NAME/g" "$tmpl" > "$SKILL_DIR/templates/$(basename "$tmpl")" -done +# Ship the external-plugin drop-in dir (just its README) so the location exists +# post-install. A plain cp — not cp -R --delete — preserves any plugins the user +# dropped in and their db/trusted-plugins opt-ins. +mkdir -p "$SKILL_DIR/plugins" +cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || true cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh +chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true +# Re-point an existing Codex monitor shim at the new path on a reinstall over an +# older layout (no-op when no agmsg shim is present). See the --update block above. +CODEX_SHIM="$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" +if [ -x "$CODEX_SHIM" ] && AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" status 2>/dev/null | grep -q '^installed:'; then + AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$CODEX_SHIM" install >/dev/null 2>&1 \ + && echo " + refreshed Codex monitor shim (~/.agents/bin/codex)" +fi install_windows_helpers # Marker file for uninstall detection @@ -339,7 +374,7 @@ printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" # Initialize DB if [ ! -f "$SKILL_DIR/db/messages.db" ]; then - bash "$SKILL_DIR/scripts/init-db.sh" + bash "$SKILL_DIR/scripts/internal/init-db.sh" fi # Initialize config @@ -352,7 +387,7 @@ fi CC_COMMANDS_DIR="$HOME/.claude/commands" if [ -d "$HOME/.claude" ]; then mkdir -p "$CC_COMMANDS_DIR" - sed "s/__SKILL_NAME__/$CMD_NAME/g" "$SCRIPT_DIR/templates/cmd.claude-code.md" > "$CC_COMMANDS_DIR/$CMD_NAME.md" + sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path claude-code)" > "$CC_COMMANDS_DIR/$CMD_NAME.md" echo " + installed /$CMD_NAME command to ~/.claude/commands/" fi @@ -363,7 +398,7 @@ fi COPILOT_SKILL_DIR="$HOME/.copilot/skills/$CMD_NAME" if [ -d "$HOME/.copilot" ]; then mkdir -p "$COPILOT_SKILL_DIR" - sed "s/__SKILL_NAME__/$CMD_NAME/g" "$SCRIPT_DIR/templates/cmd.copilot.md" > "$COPILOT_SKILL_DIR/SKILL.md" + sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path copilot)" > "$COPILOT_SKILL_DIR/SKILL.md" echo " + installed /$CMD_NAME skill to ~/.copilot/skills/" fi @@ -375,10 +410,22 @@ fi OPENCODE_SKILL_DIR="$HOME/.config/opencode/skills/$CMD_NAME" if [ -d "$HOME/.config/opencode" ]; then mkdir -p "$OPENCODE_SKILL_DIR" - sed "s/__SKILL_NAME__/$CMD_NAME/g" "$SCRIPT_DIR/templates/cmd.opencode.md" > "$OPENCODE_SKILL_DIR/SKILL.md" + sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path opencode)" > "$OPENCODE_SKILL_DIR/SKILL.md" echo " + installed \$$CMD_NAME skill to ~/.config/opencode/skills/" fi +# --- Install Hermes Agent skill --- +# Hermes reads skills from ~/.hermes/skills//SKILL.md. Runtime scripts and +# the shared SQLite store stay in ~/.agents/skills// so Hermes shares the +# same message floor as the other agents. Hermes has no automatic delivery hook +# (manual inbox checks only), but the skill itself installs the same way. +HERMES_SKILL_DIR="$HOME/.hermes/skills/$CMD_NAME" +if [ -d "$HOME/.hermes" ]; then + mkdir -p "$HERMES_SKILL_DIR" + sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path hermes)" > "$HERMES_SKILL_DIR/SKILL.md" + echo " + installed /$CMD_NAME skill to ~/.hermes/skills/" +fi + # Codex sandbox writable_roots are configured by configure_codex_sandbox() at # the "Done" step below — the single source of truth for db/, teams/, and run/. # (A legacy inline copy used to run here too, which double-mutated the array and diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 0000000..35bc740 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,5 @@ +# The repo ships only this dir's README. Anything dropped here for local testing +# (and the runtime db/trusted-plugins opt-ins) stays out of version control. +* +!.gitignore +!README.md diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..f7a0202 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,32 @@ +# plugins/ + +Drop external **drivers** here to extend agmsg without forking it. This is the +default plugin search base (`/plugins/`); `$AGMSG_PLUGIN_DIRS` can add more. + +Layout mirrors the built-ins under `scripts/drivers/` — one directory per driver, +grouped by axis: + +``` +plugins/ +└── / # types | storage | delivery + └── / # e.g. plugins/types/foo/ + └── … # same layout as a built-in driver of that axis +``` + +For the `types` axis, a plugin is exactly a built-in agent type placed here (a +`type.conf` manifest + `template.md`, plus an optional `_delivery.sh`). See +[../docs/agent-types.md](../docs/agent-types.md). + +**Trust is required.** A driver is shell code that runs with your privileges, so +anything dropped here is **ignored until you opt in**: + +``` +agmsg plugin list # shows it as UNTRUSTED +agmsg plugin trust types/foo # opt in (path-pinned) +``` + +Full details: [../docs/plugins.md](../docs/plugins.md) · +rationale: [../docs/adr/0002-driver-discovery-and-plugin-opt-in.md](../docs/adr/0002-driver-discovery-and-plugin-opt-in.md) + +> This directory ships with only this README. Your trusted plugins and the +> `db/trusted-plugins` allowlist are preserved across `--update` installs. diff --git a/scripts/check-inbox.sh b/scripts/check-inbox.sh index 9ab3d76..dc067b7 100755 --- a/scripts/check-inbox.sh +++ b/scripts/check-inbox.sh @@ -14,6 +14,17 @@ source "$SCRIPT_DIR/lib/storage.sh" source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/resolve-project.sh" # agmsg_agent_pid, for instance-id derivation +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" + +# Some Stop-hook runtimes (codex, copilot) want an explicit JSON status object +# even when there is nothing to deliver; others (claude-code) stay silent. This +# is the type's manifest `stop_output=` (data), not a hardcoded type list. +STOP_OUTPUT="$(agmsg_type_get "$TYPE" stop_output 2>/dev/null || true)" +emit_status_json() { + [ "$STOP_OUTPUT" = "json" ] || return 0 + printf '{\n "continue": true,\n "systemMessage": "%s"\n}\n' "$1" +} # Hook runtimes that pass JSON do so on stdin. Interactive invocations such as # Gemini's PostToolUse command may inherit a terminal stdin instead; reading @@ -86,16 +97,7 @@ if [ -f "$MARKER" ]; then [ -z "$INTERVAL" ] && INTERVAL=$("$SCRIPT_DIR/config.sh" get hook.check_interval 60) case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=60 ;; esac if [ $(( now - last )) -lt "$INTERVAL" ]; then - case "$TYPE" in - codex|copilot) - cat <<'ENDJSON' -{ - "continue": true, - "systemMessage": "agmsg: check skipped (cooldown)" -} -ENDJSON - ;; - esac + emit_status_json "agmsg: check skipped (cooldown)" exit 0 fi fi @@ -145,16 +147,7 @@ done # No new messages if [ -z "$OUTPUT" ]; then - case "$TYPE" in - codex|copilot) - cat <<'ENDJSON' -{ - "continue": true, - "systemMessage": "agmsg: no new messages" -} -ENDJSON - ;; - esac + emit_status_json "agmsg: no new messages" exit 0 fi diff --git a/scripts/delivery.sh b/scripts/delivery.sh index cb6e90d..213c8c5 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -40,329 +40,59 @@ RUN_DIR="$SKILL_DIR/run" # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/node.sh" # shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/type-registry.sh" +# storage.sh provides agmsg_sqlite_mem (CR-safe sqlite, #180); hooks-json.sh's +# primitives use it, so source storage first. +# shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/storage.sh" +# JSON/SQLite hook-file primitives (sourced after SKILL_NAME is set above — +# strip/add reference it to detect agmsg-owned entries). +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/hooks-json.sh" +# Shared "rule-file" delivery behavior (rulefile_apply), delegated to by the +# rule-file types' _delivery.sh plugs. +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/delivery-rulefile.sh" +# The per-project delivery hooks file is the type's manifest `hooks_file=` +# (project-relative), not a hardcoded per-type case. The hook FORMAT written into +# it is still type-specific (apply_settings_* below). resolve_hooks_file() { local type="$1" local project="$2" - case "$type" in - claude-code) echo "$project/.claude/settings.local.json" ;; - codex) echo "$project/.codex/hooks.json" ;; - gemini|antigravity) echo "$project/.agent/rules/agmsg.md" ;; - copilot) echo "$project/.github/hooks/agmsg.json" ;; - opencode) echo "$project/.opencode/rules/agmsg.md" ;; - *) echo "Unknown agent type: $type" >&2; return 1 ;; - esac -} - -sql_readfile_path() { - local path="$1" - if command -v cygpath >/dev/null 2>&1; then - path=$(cygpath -w "$path" 2>/dev/null || printf '%s' "$path") - fi - printf '%s' "$path" | sed "s/'/''/g" -} - -# Strip any agmsg-owned hook entries from in the JSON at . An -# entry is "agmsg-owned" when one of its inner hooks references a path under -# our skill directory. Result is written back to atomically. -# -# Reads the settings via sqlite3's readfile() rather than interpolating the -# file's contents into the SQL string. The old in-memory chain embedded the -# settings blob 6× into a single sqlite3 argv element; on Linux that hits -# the per-arg MAX_ARG_STRLEN cap (131072 bytes) once the settings file -# crosses ~21 KB, so `delivery.sh set` failed with E2BIG (see #95). Using -# readfile() keeps the file off the argv entirely. -strip_agmsg_event_file() { - local path="$1" - local event="$2" - local sql_path - sql_path=$(sql_readfile_path "$path") - local tmp tmp_sql - tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") - tmp_sql=$(sql_readfile_path "$tmp") - # Write the result with writefile() rather than redirecting sqlite3's CLI - # output. On strict sqlite3 builds (>= 3.50, shipped on Windows) the CLI - # renders control bytes — e.g. a CR that rode in on a CRLF settings file — - # using caret notation ("^M"), corrupting the JSON so the next read fails - # with "malformed JSON" (#143/#138, same root cause as #102). writefile() - # emits the bytes verbatim. See also strip's readfile() (#95). - # Validate writefile()'s result, not just sqlite3's exit code. writefile() - # returns the byte count written and yields NULL on a failed write (e.g. an - # unwritable tmp dir) — but sqlite3 still exits 0, so an exit-code-only check - # would mv an empty/partial tmp over the original. Compare the bytes written - # to the content's byte length (CAST AS BLOB so multibyte content isn't - # miscounted by character-based length()); anything but an exact match fails. - # Guard contributed in #162 (kevinsj15). - local wrote - wrote=$(agmsg_sqlite_mem " - WITH src AS (SELECT readfile('$sql_path') AS j), - out AS (SELECT coalesce(CASE - WHEN json_extract(src.j, '\$.hooks.$event') IS NULL THEN - src.j - WHEN (SELECT count(*) FROM json_each(json_extract(src.j, '\$.hooks.$event')) AS s - WHERE NOT EXISTS ( - SELECT 1 FROM json_each(json_extract(s.value, '\$.hooks')) AS h - WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 - )) = 0 THEN - json_remove(src.j, '\$.hooks.$event') - ELSE - json_set(src.j, '\$.hooks.$event', - (SELECT json_group_array(json(s.value)) - FROM json_each(json_extract(src.j, '\$.hooks.$event')) AS s - WHERE NOT EXISTS ( - SELECT 1 FROM json_each(json_extract(s.value, '\$.hooks')) AS h - WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 - )) - ) - END, '') AS blob FROM src) - SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; - ") || { rm -f "$tmp"; return 1; } - [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } - mv "$tmp" "$path" -} - -# Wrap a POSIX shell command so Codex's Windows runner executes it through Git -# Bash. On native Windows, Codex runs each hook command via PowerShell, which -# cannot execute a bare POSIX ".sh" path, so the hook exits non-zero. Codex hook -# config supports a "commandWindows" key that takes precedence on Windows. -windows_wrap() { - local posix_cmd="$1" - local bash_cmd_ps - bash_cmd_ps=$(printf '%s' "$posix_cmd" | sed "s/'/''/g") - printf "\$b=\$env:GIT_BASH; if (-not \$b) { \$b=\$env:AGMSG_BASH }; if (-not \$b) { \$b='C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe' }; & \$b -lc '%s'" "$bash_cmd_ps" -} - -# Append a single entry of the form {"matcher":"","hooks":[{"type":"command","command":""}]} -# to .hooks. in the JSON at , creating arrays/objects as needed. -# For Codex agents (pass "codex" as the 4th arg) the entry also carries a -# "commandWindows" so the hook runs on native Windows; other agent types are -# unchanged. Writes the result back to . As with strip_agmsg_event_file, -# the settings are read via readfile() rather than via argv (#95). -add_event_entry_file() { - local path="$1" - local event="$2" - local cmd="$3" - local hook_type="${4:-}" - local sql_path - sql_path=$(sql_readfile_path "$path") - - # Build the entry with SQLite's own json_object()/json_array() so SQLite does - # every JSON-level escape. Raw values go in as ordinary SQL string literals - # (single quotes doubled) — the only escaping this layer needs. Hand-building - # the JSON string instead (and only escaping the codex commandWindows) left - # the "command" value's embedded " and ' unescaped, producing "malformed - # JSON" on tricky project paths and on native Windows sqlite builds (#134). - local cmd_lit - cmd_lit=$(printf '%s' "$cmd" | sed "s/'/''/g") - local hook_obj="json_object('type','command','command','$cmd_lit'" - if [ "$hook_type" = "codex" ]; then - local cw cw_lit - cw=$(windows_wrap "$cmd") - cw_lit=$(printf '%s' "$cw" | sed "s/'/''/g") - hook_obj="$hook_obj,'commandWindows','$cw_lit'" + local rel + rel="$(agmsg_type_get "$type" hooks_file)" + if [ -z "$rel" ]; then + echo "Unknown agent type: $type" >&2 + return 1 fi - hook_obj="$hook_obj)" - local entry_sql="json_object('matcher','','hooks',json_array($hook_obj))" - - local tmp tmp_sql - tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") - tmp_sql=$(sql_readfile_path "$tmp") - # writefile() instead of CLI redirect — see strip_agmsg_event_file for why - # (strict sqlite3 caret-escapes control bytes in CLI output, #143/#102). - # Validate writefile()'s byte count vs the content length — see - # strip_agmsg_event_file for why the exit code alone is insufficient (#162). - local wrote - wrote=$(agmsg_sqlite_mem " - WITH base AS ( - SELECT CASE WHEN json_extract(readfile('$sql_path'), '\$.hooks') IS NULL - THEN json_set(readfile('$sql_path'), '\$.hooks', json('{}')) - ELSE readfile('$sql_path') END AS s - ), - out AS (SELECT CASE - WHEN json_extract(s, '\$.hooks.$event') IS NULL THEN - json_set(s, '\$.hooks.$event', json_array($entry_sql)) - ELSE - json_set(s, '\$.hooks.$event', - (SELECT json_group_array(json(v.value)) FROM ( - SELECT value FROM json_each(json_extract(s, '\$.hooks.$event')) - UNION ALL - SELECT $entry_sql - ) v) - ) - END AS blob FROM base) - SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; - ") || { rm -f "$tmp"; return 1; } - [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } - mv "$tmp" "$path" -} - -# Drop the entire .hooks object if it ended up empty after stripping. Reads -# and writes via readfile() — see strip_agmsg_event_file for the -# rationale (#95). -prune_empty_hooks_file() { - local path="$1" - local sql_path - sql_path=$(sql_readfile_path "$path") - local tmp tmp_sql - tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") - tmp_sql=$(sql_readfile_path "$tmp") - # writefile() instead of CLI redirect — see strip_agmsg_event_file (#143/#102). - # Validate writefile()'s byte count vs the content length — see - # strip_agmsg_event_file for why the exit code alone is insufficient (#162). - local wrote - wrote=$(agmsg_sqlite_mem " - WITH src AS (SELECT readfile('$sql_path') AS j), - out AS (SELECT coalesce(CASE - WHEN json_extract(src.j, '\$.hooks') IS NULL THEN src.j - WHEN (SELECT count(*) FROM json_each(json_extract(src.j, '\$.hooks'))) = 0 THEN - json_remove(src.j, '\$.hooks') - ELSE src.j - END, '') AS blob FROM src) - SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; - ") || { rm -f "$tmp"; return 1; } - [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } - mv "$tmp" "$path" -} - -apply_settings_opencode() { - local type="$1" - local project="$2" - local mode="$3" - local rule_file - rule_file=$(resolve_hooks_file "$type" "$project") - - case "$mode" in - turn|off) ;; - monitor|both) - echo "Error: '$mode' mode is not supported for $type (no Monitor-tool equivalent). Use 'turn' or 'off'." >&2 - return 1 - ;; - *) - echo "Unknown mode: $mode (use turn|off)" >&2 - return 1 - ;; + # hooks_file is project-relative; reject absolute paths or traversal so a + # manifest can't redirect writes outside the project. + case "$rel" in + /*|*..*) echo "Invalid hooks_file for $type: $rel" >&2; return 1 ;; esac - - rm -f "$rule_file" - - if [ "$mode" = "turn" ]; then - mkdir -p "$(dirname "$rule_file")" - cat < "$rule_file" -# agmsg Integration Rule - -## PostToolUse -After each tool call, automatically check the agmsg inbox for unread messages. -- Command: '$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project' -EOF - fi + echo "$project/$rel" } -apply_settings_copilot() { +# Default delivery behavior: JSON event-hooks (SessionStart / SessionEnd / Stop) +# written into the type's hooks_file. Used by claude-code and codex. Rule-file +# types override this by defining agmsg_delivery_apply in scripts/drivers/types//_delivery.sh. +agmsg_delivery_apply_default() { local type="$1" local project="$2" local mode="$3" - local hooks_file - hooks_file=$(resolve_hooks_file "$type" "$project") - - # Validate the mode BEFORE touching any existing file. Rejecting - # monitor/both must not destroy a working turn hook. - case "$mode" in - turn|off) ;; - monitor|both) - echo "Error: '$mode' mode is not supported for $type (no Monitor-tool equivalent). Use 'turn' or 'off'." >&2 - return 1 - ;; - *) - echo "Unknown mode: $mode (use turn|off)" >&2 - return 1 - ;; - esac - - # Strip first so re-applying turn is an idempotent rewrite and turn->off - # cleanly removes the file. - rm -f "$hooks_file" - - if [ "$mode" = "turn" ]; then - mkdir -p "$(dirname "$hooks_file")" - local cmd="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" - # json_quote handles JSON-string escaping for arbitrary command strings - # (project paths may contain JSON-special chars). - local cmd_json - cmd_json=$(agmsg_sqlite_mem "SELECT json_quote('$(printf '%s' "$cmd" | sed "s/'/''/g")');") - # Use PascalCase 'Stop' trigger so the input payload field names match - # the snake_case form (session_id) that check-inbox.sh already parses. - cat < "$hooks_file" -{ - "version": 1, - "hooks": { - "Stop": [ - { - "type": "command", - "bash": $cmd_json, - "timeoutSec": 30 - } - ] - } -} -EOF - fi -} - -apply_settings_gemini() { - local type="$1" - local project="$2" - local mode="$3" - local rule_file - rule_file=$(resolve_hooks_file "$type" "$project") - - # Remove existing rule file - rm -f "$rule_file" - - case "$mode" in - turn|both) - mkdir -p "$(dirname "$rule_file")" - cat < "$rule_file" -# agmsg Integration Rule - -## PostToolUse -After each tool call, automatically check the agmsg inbox for unread messages. -- Command: '$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project' -EOF - ;; - monitor) - echo "Warning: 'monitor' mode is not fully supported for $type yet. Using turn-based hook." >&2 - apply_settings_gemini "$type" "$project" "turn" - ;; - off) - ;; - esac -} - -apply_settings() { - local type="$1" - local project="$2" - local mode="$3" - - if [ "$type" = "gemini" ] || [ "$type" = "antigravity" ]; then - apply_settings_gemini "$type" "$project" "$mode" - return - fi - - if [ "$type" = "copilot" ]; then - apply_settings_copilot "$type" "$project" "$mode" - return - fi - - if [ "$type" = "opencode" ]; then - apply_settings_opencode "$type" "$project" "$mode" - return - fi local hooks_file hooks_file=$(resolve_hooks_file "$type" "$project") mkdir -p "$(dirname "$hooks_file")" + # Whether hook entries also need a Windows-native "commandWindows" variant is + # a per-type manifest fact (hook_windows_wrap=yes). Resolve it here — the layer + # that knows agent types — and pass a plain flag down to add_event_entry_file, + # which stays type-agnostic (see hooks-json.sh header). + local ww + ww=$(agmsg_type_get "$type" hook_windows_wrap 2>/dev/null || true) + # Work on a temp copy so a partially-modified file never replaces the # original until the whole chain succeeds. local tmp_state @@ -383,20 +113,20 @@ apply_settings() { monitor) local ss="'$SKILL_DIR/scripts/session-start.sh' '$type' '$project'" local se="'$SKILL_DIR/scripts/session-end.sh' '$type' '$project'" - add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$type" - add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$type" + add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$ww" + add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$ww" ;; turn) local cmd="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" - add_event_entry_file "$tmp_state" "Stop" "$cmd" "$type" + add_event_entry_file "$tmp_state" "Stop" "$cmd" "$ww" ;; both) local ss="'$SKILL_DIR/scripts/session-start.sh' '$type' '$project'" local se="'$SKILL_DIR/scripts/session-end.sh' '$type' '$project'" local st="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" - add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$type" - add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$type" - add_event_entry_file "$tmp_state" "Stop" "$st" "$type" + add_event_entry_file "$tmp_state" "SessionStart" "$ss" "$ww" + add_event_entry_file "$tmp_state" "SessionEnd" "$se" "$ww" + add_event_entry_file "$tmp_state" "Stop" "$st" "$ww" ;; off) : # already stripped @@ -413,6 +143,85 @@ apply_settings() { mv "$tmp_state" "$hooks_file" } +# Default delivery entry points (Template Method). A type's plug +# (scripts/drivers/types//_delivery.sh) may override any subset of these: +# agmsg_delivery_apply — write the hook file for a mode (default: JSON event-hooks) +# agmsg_delivery_on_enable — side effects when enabling monitor/both (default: none) +# agmsg_delivery_on_disable — side effects when turning delivery off (default: none) +# A plug that wants the default apply can delegate to agmsg_delivery_apply_default. +agmsg_delivery_apply() { agmsg_delivery_apply_default "$@"; } +agmsg_delivery_on_enable() { :; } +# Default 'off' teardown: stop this project's watch.sh watchers. A type with its +# own runtime (e.g. codex's bridge) overrides this. Args: . +agmsg_delivery_on_disable() { kill_all_watchers "$2" >/dev/null 2>&1 || true; } + +# Default delivery status (json-hooks types: claude-code, codex). Derives the mode +# from the settings hooks file's agmsg-owned SessionStart/Stop entries, then prints +# the per-event entry detail. Rule-file types override agmsg_delivery_status. +agmsg_delivery_status_default() { + local type="$1" project="$2" + local hf + hf=$(resolve_hooks_file "$type" "$project") + local has_ss=0 has_st=0 + if [ -f "$hf" ]; then + local sql_hf + sql_hf=$(sql_readfile_path "$hf") + has_ss=$(agmsg_sqlite_mem " + SELECT EXISTS( + SELECT 1 FROM json_each(json_extract(readfile('$sql_hf'), '\$.hooks.SessionStart')) AS s, + json_each(json_extract(s.value, '\$.hooks')) AS h + WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 + );" 2>/dev/null || echo 0) + has_st=$(agmsg_sqlite_mem " + SELECT EXISTS( + SELECT 1 FROM json_each(json_extract(readfile('$sql_hf'), '\$.hooks.Stop')) AS s, + json_each(json_extract(s.value, '\$.hooks')) AS h + WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 + );" 2>/dev/null || echo 0) + fi + local mode="off" + if [ "$has_ss" = "1" ] && [ "$has_st" = "1" ]; then mode="both" + elif [ "$has_ss" = "1" ]; then mode="monitor" + elif [ "$has_st" = "1" ]; then mode="turn" + fi + echo "mode: $mode" + + if [ -f "$hf" ]; then + local sql_hf count + sql_hf=$(sql_readfile_path "$hf") + # readfile() rather than interpolating the file contents into argv — + # for large settings (#95) the latter hits MAX_ARG_STRLEN on Linux. + count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hf'), '\$.hooks.SessionStart'));" 2>/dev/null || echo 0) + case "$count" in ''|*[!0-9]*) count=0 ;; esac + echo "settings hooks file: $hf" + echo " SessionStart entries: $count" + count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hf'), '\$.hooks.SessionEnd'));" 2>/dev/null || echo 0) + case "$count" in ''|*[!0-9]*) count=0 ;; esac + echo " SessionEnd entries: $count" + count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hf'), '\$.hooks.Stop'));" 2>/dev/null || echo 0) + case "$count" in ''|*[!0-9]*) count=0 ;; esac + echo " Stop entries: $count" + fi +} +agmsg_delivery_status() { agmsg_delivery_status_default "$@"; } + +# Source the type's delivery plug (if present) so its overrides take effect. +# One type is handled per invocation, so the global overrides never go stale. +agmsg_delivery_load_plug() { + local tdir + tdir="$(agmsg_type_dir "$1" 2>/dev/null || true)" + if [ -n "$tdir" ] && [ -f "$tdir/_delivery.sh" ]; then + # shellcheck disable=SC1090 + . "$tdir/_delivery.sh" + fi +} + +apply_settings() { + local type="$1" project="$2" mode="$3" + agmsg_delivery_load_plug "$type" + agmsg_delivery_apply "$type" "$project" "$mode" +} + CODEX_MONITOR_DOC_URL="https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" emit_monitor_directive() { @@ -508,13 +317,27 @@ do_set() { local TYPE="${2:?Missing type}" local PROJECT="${3:?Missing project_path}" + # Two-stage validation. First: is this even a real mode? The four mode names + # are engine vocabulary (not type-specific), so a typo is caught here with a + # generic message before any per-type logic. case "$MODE" in monitor|turn|both|off) ;; *) echo "Unknown mode: $MODE (use monitor|turn|both|off)" >&2; exit 1 ;; esac - if [ "$TYPE" = "codex" ] && [ "$MODE" = "both" ]; then - echo "Error: 'both' mode is not supported for codex bridge beta. Use 'monitor', 'turn', or 'off'." >&2 - exit 1 - fi + # Second: does THIS type accept the mode? A type declares the modes its CLI + # accepts via the delivery_modes= manifest key (e.g. codex omits 'both' — the + # bridge beta has no both-mode; rule-file types like opencode omit + # 'monitor'/'both'). Reject anything not listed, before any file is touched. + # Types without the key fall back to the full set so an unconfigured manifest + # still works. + local SUPPORTED_MODES + SUPPORTED_MODES=$(agmsg_type_get "$TYPE" delivery_modes 2>/dev/null || true) + [ -z "$SUPPORTED_MODES" ] && SUPPORTED_MODES="monitor turn both off" + case " $SUPPORTED_MODES " in + *" $MODE "*) ;; + *) + echo "Error: '$MODE' mode is not supported for $TYPE (supported: $SUPPORTED_MODES)." >&2 + exit 1 ;; + esac apply_settings "$TYPE" "$PROJECT" "$MODE" @@ -522,49 +345,9 @@ do_set() { case "$MODE" in monitor|both) - if [ "$TYPE" = "codex" ]; then - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/scripts/codex-shim-install.sh" install; then - echo "Codex monitor shim installed at ~/.agents/bin/codex." - case ":$PATH:" in - *":$HOME/.agents/bin:"*) - echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg shim routes interactive Codex sessions through the bridge." - ;; - *) - # Loud, unambiguous: this is the #1 reason monitor silently does nothing. - echo "WARNING: ~/.agents/bin is NOT on your PATH, so 'codex' still launches the real" - echo " binary and the monitor bridge will NOT engage. Add this line, restart your shell," - echo " then launch with codex:" - echo " export PATH=\"\$HOME/.agents/bin:\$PATH\"" - ;; - esac - else - echo "Codex monitor mode is enabled, but the codex shim was not installed." - echo "Future Codex sessions: launch with $SKILL_DIR/scripts/codex-monitor.sh, or resolve the shim install issue above." - fi - # Node preflight: the bridge (codex-bridge.js) is a Node program, so - # without Node it silently never starts — flag it here at enable time. - # Presence only: the bridge uses old/stable APIs (the sole modern feature - # is one optional-chaining call, Node 14+), and any Node new enough to run - # Codex itself runs the bridge — so a version gate would be noise. - # Resolve via the same path the runtime uses (lib/node.sh) so this warning - # matches what the launcher will actually do — including a version-manager - # Node off PATH. AGMSG_NODE / AGMSG_CODEX_NODE override the binary. - codex_node="$(agmsg_resolve_node)" - if ! command -v "$codex_node" >/dev/null 2>&1 && [ ! -x "$codex_node" ]; then - echo "WARNING: Node.js ('$codex_node') was not found. The Codex bridge needs Node —" - echo " monitor delivery will NOT start until Node is installed (or set AGMSG_NODE)." - fi - # The bridge launches from the Codex SessionStart hook, which fires on the - # FIRST turn of a new session (not the moment Codex opens) — and an - # already-running session is not retrofitted (#151; launcher internals #153). - echo "Restart your Codex session (quit and relaunch \`codex\`), then send your first" - echo " message — the bridge starts on your first turn, not the moment Codex opens." - echo " Already-running sessions stay unmonitored until they restart." - echo "For more info: $CODEX_MONITOR_DOC_URL" - else - echo "Future sessions: SessionStart hook will auto-launch the watcher." - emit_monitor_directive "$TYPE" "$PROJECT" - fi + # Type-specific enable side effects (shim install, watcher directive, …) + # live in the type's plug as agmsg_delivery_on_enable; default is none. + agmsg_delivery_on_enable "$MODE" "$TYPE" "$PROJECT" ;; turn) echo "Future sessions: Stop hook will check inbox between turns." @@ -574,20 +357,17 @@ do_set() { ;; off) echo "Future sessions: no automatic delivery." - if [ "$TYPE" = "codex" ]; then - local stopped - stopped=$(stop_codex_bridge "$PROJECT") - if [ "${stopped:-0}" -gt 0 ]; then - echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." - fi - echo "Note: the codex shim (~/.agents/bin/codex) is shared across projects, so it was left in place." - echo " If no other project uses monitor mode, remove it and restore your PATH:" - echo " $SKILL_DIR/scripts/codex-shim-install.sh remove" - echo " # then drop ~/.agents/bin from PATH if you added it for monitor" - else - kill_all_watchers "$PROJECT" >/dev/null 2>&1 || true - fi - emit_stop_directive + # Type-specific teardown via the plug (default: stop this project's + # watchers; codex stops its bridge instead). + agmsg_delivery_on_disable "$TYPE" "$PROJECT" + # Only emit the in-session watcher-stop directive for types that actually + # have an automatic delivery mode to stop. A manual-only type + # (delivery_modes=off, e.g. hermes) has no Monitor/watcher, so the + # directive would be noise — and a stray TaskStop could disturb an + # unrelated agent's watcher. Data-driven, so no per-type branch here. + case " $SUPPORTED_MODES " in + *" monitor "*|*" turn "*|*" both "*) emit_stop_directive ;; + esac ;; esac } @@ -600,62 +380,11 @@ do_status() { # global mode value. When called without , we can't infer # a project-scoped mode, so we just skip the mode line and report the # global watcher state below. + # Mode + per-type status detail come from the type's delivery plug + # (agmsg_delivery_status); default is JSON event-hooks, rule-file types override. if [ -n "$TYPE" ] && [ -n "$PROJECT" ]; then - local hf - hf=$(resolve_hooks_file "$TYPE" "$PROJECT") - if [ "$TYPE" = "gemini" ] || [ "$TYPE" = "antigravity" ] || [ "$TYPE" = "copilot" ] || [ "$TYPE" = "opencode" ]; then - local mode="off" - if [ -f "$hf" ]; then - mode="turn" - fi - echo "mode: $mode" - else - local has_ss=0 has_st=0 - if [ -f "$hf" ]; then - local sql_hf - sql_hf=$(sql_readfile_path "$hf") - has_ss=$(agmsg_sqlite_mem " - SELECT EXISTS( - SELECT 1 FROM json_each(json_extract(readfile('$sql_hf'), '\$.hooks.SessionStart')) AS s, - json_each(json_extract(s.value, '\$.hooks')) AS h - WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 - );" 2>/dev/null || echo 0) - has_st=$(agmsg_sqlite_mem " - SELECT EXISTS( - SELECT 1 FROM json_each(json_extract(readfile('$sql_hf'), '\$.hooks.Stop')) AS s, - json_each(json_extract(s.value, '\$.hooks')) AS h - WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 - );" 2>/dev/null || echo 0) - fi - local mode="off" - if [ "$has_ss" = "1" ] && [ "$has_st" = "1" ]; then mode="both" - elif [ "$has_ss" = "1" ]; then mode="monitor" - elif [ "$has_st" = "1" ]; then mode="turn" - fi - echo "mode: $mode" - fi - fi - - if [ -n "$TYPE" ] && [ -n "$PROJECT" ] && [ "$TYPE" != "gemini" ] && [ "$TYPE" != "antigravity" ] && [ "$TYPE" != "copilot" ] && [ "$TYPE" != "opencode" ]; then - local hooks_file - hooks_file=$(resolve_hooks_file "$TYPE" "$PROJECT") - if [ -f "$hooks_file" ]; then - local count - local sql_hooks_file - sql_hooks_file=$(sql_readfile_path "$hooks_file") - # readfile() rather than interpolating the file contents into argv — - # for large settings (#95) the latter hits MAX_ARG_STRLEN on Linux. - count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hooks_file'), '\$.hooks.SessionStart'));" 2>/dev/null || echo 0) - case "$count" in ''|*[!0-9]*) count=0 ;; esac - echo "settings hooks file: $hooks_file" - echo " SessionStart entries: $count" - count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hooks_file'), '\$.hooks.SessionEnd'));" 2>/dev/null || echo 0) - case "$count" in ''|*[!0-9]*) count=0 ;; esac - echo " SessionEnd entries: $count" - count=$(agmsg_sqlite_mem "SELECT json_array_length(json_extract(readfile('$sql_hooks_file'), '\$.hooks.Stop'));" 2>/dev/null || echo 0) - case "$count" in ''|*[!0-9]*) count=0 ;; esac - echo " Stop entries: $count" - fi + agmsg_delivery_load_plug "$TYPE" + agmsg_delivery_status "$TYPE" "$PROJECT" fi if [ -d "$RUN_DIR" ]; then diff --git a/scripts/drivers/types/antigravity/_delivery.sh b/scripts/drivers/types/antigravity/_delivery.sh new file mode 100644 index 0000000..a5d8460 --- /dev/null +++ b/scripts/drivers/types/antigravity/_delivery.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# antigravity delivery plug — rule-file integration (same shape as gemini). +# rulefile_apply is provided by scripts/lib/delivery-rulefile.sh. +agmsg_delivery_apply() { rulefile_apply "$@"; } +agmsg_delivery_status() { rulefile_status "$@"; } diff --git a/templates/cmd.antigravity.md b/scripts/drivers/types/antigravity/template.md similarity index 100% rename from templates/cmd.antigravity.md rename to scripts/drivers/types/antigravity/template.md diff --git a/scripts/drivers/types/antigravity/type.conf b/scripts/drivers/types/antigravity/type.conf new file mode 100644 index 0000000..01f4f5c --- /dev/null +++ b/scripts/drivers/types/antigravity/type.conf @@ -0,0 +1,7 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=antigravity +template=template.md +detect=explicit +hooks_file=.agent/rules/agmsg.md +monitor=no +delivery_modes=monitor turn both off diff --git a/scripts/drivers/types/claude-code/_delivery.sh b/scripts/drivers/types/claude-code/_delivery.sh new file mode 100644 index 0000000..49d8bb0 --- /dev/null +++ b/scripts/drivers/types/claude-code/_delivery.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# claude-code delivery plug. +# +# Uses the default JSON event-hooks apply (agmsg_delivery_apply). On enable +# (monitor/both) it emits the in-session Monitor directive so a running Claude +# Code session starts streaming immediately. Sourced into delivery.sh's context, +# so emit_monitor_directive is in scope. Args: on_enable . +agmsg_delivery_on_enable() { + echo "Future sessions: SessionStart hook will auto-launch the watcher." + emit_monitor_directive "$2" "$3" +} diff --git a/templates/cmd.claude-code.md b/scripts/drivers/types/claude-code/template.md similarity index 100% rename from templates/cmd.claude-code.md rename to scripts/drivers/types/claude-code/template.md diff --git a/scripts/drivers/types/claude-code/type.conf b/scripts/drivers/types/claude-code/type.conf new file mode 100644 index 0000000..616a992 --- /dev/null +++ b/scripts/drivers/types/claude-code/type.conf @@ -0,0 +1,10 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=claude-code +template=template.md +cli=claude +spawnable=yes +detect=CLAUDE_CODE_SESSION_ID +detect_proc=claude claude-code claude-* +hooks_file=.claude/settings.local.json +monitor=yes +delivery_modes=monitor turn both off diff --git a/scripts/drivers/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh new file mode 100644 index 0000000..32918ed --- /dev/null +++ b/scripts/drivers/types/codex/_delivery.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# codex delivery plug. +# +# codex keeps the default JSON event-hooks apply (agmsg_delivery_apply); it only +# adds enable/disable side effects: install the monitor shim on enable, stop the +# bridge on disable. Sourced into delivery.sh's context, so SKILL_DIR, +# agmsg_resolve_node, CODEX_MONITOR_DOC_URL and stop_codex_bridge are in scope. +# Args (both hooks): on_enable ; on_disable . + +agmsg_delivery_on_enable() { + if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh" install; then + echo "Codex monitor shim installed at ~/.agents/bin/codex." + case ":$PATH:" in + *":$HOME/.agents/bin:"*) + echo "Future Codex sessions: launch with codex. In monitor-mode projects, the agmsg shim routes interactive Codex sessions through the bridge." + ;; + *) + # Loud, unambiguous: this is the #1 reason monitor silently does nothing. + echo "WARNING: ~/.agents/bin is NOT on your PATH, so 'codex' still launches the real" + echo " binary and the monitor bridge will NOT engage. Add this line, restart your shell," + echo " then launch with codex:" + echo " export PATH=\"\$HOME/.agents/bin:\$PATH\"" + ;; + esac + else + echo "Codex monitor mode is enabled, but the codex shim was not installed." + echo "Future Codex sessions: launch with $SKILL_DIR/scripts/drivers/types/codex/codex-monitor.sh, or resolve the shim install issue above." + fi + # Node preflight: the bridge (codex-bridge.js) is a Node program, so without + # Node it silently never starts — flag it at enable time. Resolve via the same + # path the runtime uses (lib/node.sh). AGMSG_NODE / AGMSG_CODEX_NODE override. + local codex_node + codex_node="$(agmsg_resolve_node)" + if ! command -v "$codex_node" >/dev/null 2>&1 && [ ! -x "$codex_node" ]; then + echo "WARNING: Node.js ('$codex_node') was not found. The Codex bridge needs Node —" + echo " monitor delivery will NOT start until Node is installed (or set AGMSG_NODE)." + fi + echo "Restart your Codex session (quit and relaunch \`codex\`), then send your first" + echo " message — the bridge starts on your first turn, not the moment Codex opens." + echo " Already-running sessions stay unmonitored until they restart." + echo "For more info: $CODEX_MONITOR_DOC_URL" +} + +agmsg_delivery_on_disable() { + local project="$2" + local stopped + stopped=$(stop_codex_bridge "$project") + if [ "${stopped:-0}" -gt 0 ]; then + echo "Stopped $stopped Codex bridge process(es) for this project and cleaned their run files." + fi + echo "Note: the codex shim (~/.agents/bin/codex) is shared across projects, so it was left in place." + echo " If no other project uses monitor mode, remove it and restore your PATH:" + echo " $SKILL_DIR/scripts/drivers/types/codex/codex-shim-install.sh remove" + echo " # then drop ~/.agents/bin from PATH if you added it for monitor" +} diff --git a/scripts/drivers/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh new file mode 100644 index 0000000..d3d2529 --- /dev/null +++ b/scripts/drivers/types/codex/_session-start.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# codex SessionStart plug — hand the session off to the Codex bridge. +# +# Sourced by session-start.sh in its global context (so it sees TYPE, PROJECT, +# RUN_DIR, SKILL_DIR, SCRIPT_DIR, PAIRS and the helpers agmsg_sha1, +# agmsg_sqlite_mem, agmsg_resolve_node, agmsg_canonical_path, agmsg_agent_pid). +# Defines agmsg_session_start, overriding session-start.sh's default no-op. +# +# Codex has no Monitor tool. When launched through codex-monitor.sh, the TUI is +# attached to a shared app-server. Hand the bridge off so incoming agmsg rows +# become turns in the current Codex thread without exposing socket/thread +# plumbing to the user. With AGMSG_CODEX_BRIDGE_LAUNCHER=1 (set by +# codex-monitor.sh) we only write a request file and let the out-of-sandbox +# launcher start the bridge — a hook-launched bridge cannot connect to the unix +# socket from inside the Codex sandbox (#41). + +# Resolve the current Codex thread id. CODEX_THREAD_ID is only exported on the +# interactive --remote path; fresh and `codex exec` sessions never export it, so +# fall back to the newest rollout file whose session_meta cwd matches the +# project. Codex writes that rollout ~1s before SessionStart, so it is already +# present; a short bounded retry covers the race if it is not. See #41. +agmsg_resolve_codex_thread() { + local project="$1" + if [ -n "${CODEX_THREAD_ID:-}" ]; then + printf '%s' "$CODEX_THREAD_ID" + return 0 + fi + local sessions_dir="$HOME/.codex/sessions" + [ -d "$sessions_dir" ] || return 0 + # Compare PHYSICAL paths. agmsg may open the project via a symlinked/logical + # path (e.g. a workspace under a symlinked home) while Codex records the + # canonical cwd in session_meta. A raw string compare then misses every + # rollout, so the thread is never resolved and the bridge never starts. See + # #160. Canonicalize the project once; canonicalize each rollout cwd per row. + local project_phys + project_phys=$(agmsg_canonical_path "$project") + local waited=0 f first esc cwd cwd_phys tid + while :; do + while IFS= read -r f; do + [ -f "$f" ] || continue + first=$(head -1 "$f" 2>/dev/null) + case "$first" in *'"session_meta"'*) ;; *) continue ;; esac + esc=$(printf '%s' "$first" | sed "s/'/''/g") + cwd=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.cwd'),'')" 2>/dev/null) + cwd_phys=$(agmsg_canonical_path "$cwd") + [ "$cwd_phys" = "$project_phys" ] || continue + tid=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.id'),'')" 2>/dev/null) + if [ -n "$tid" ]; then + printf '%s' "$tid" + return 0 + fi + done </dev/null | head -20) +INNER_EOF + [ "$waited" -ge 2 ] && break + waited=$((waited + 1)) + sleep 1 + done + return 0 +} + +agmsg_session_start() { + thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" + [ -n "$thread_id" ] || exit 0 + app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" + if [ -z "$app_server" ]; then + agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) + if [ -n "$agent_pid" ]; then + agent_cmd=$(ps -o args= -p "$agent_pid" 2>/dev/null || true) + app_server=$(printf '%s\n' "$agent_cmd" \ + | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ + | head -1) + fi + fi + if [ -z "$app_server" ]; then + project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) + socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" + if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then + app_server="unix://$socket_path" + fi + fi + [ -n "$app_server" ] || exit 0 + + pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') + [ "$pair_count" = "1" ] || exit 0 + team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') + name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') + [ -n "$team" ] && [ -n "$name" ] || exit 0 + + if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then + project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) + request_file="$RUN_DIR/codex-bridge-request.$project_hash" + tmp_request="$request_file.$$" + mkdir -p "$RUN_DIR" 2>/dev/null || true + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$TYPE" "$team" "$name" "$thread_id" "$app_server" > "$tmp_request" + mv "$tmp_request" "$request_file" + exit 0 + fi + + mkdir -p "$RUN_DIR" 2>/dev/null || true + pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" + if [ -f "$pidfile" ]; then + bridge_pid=$(cat "$pidfile" 2>/dev/null || true) + if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null; then + exit 0 + fi + fi + + log="$RUN_DIR/codex-bridge.$team.$name.log" + # An explicit AGMSG_CODEX_BRIDGE_CMD is a complete runnable (tests, custom + # wrappers) — run it as-is. Only the default codex-bridge.js is launched + # through a resolved Node, since its env-node shebang fails in shells where a + # version-manager Node is not on PATH (#170). + if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then + bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") + else + bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js") + fi + nohup "${bridge_run[@]}" \ + --project "$PROJECT" \ + --type "$TYPE" \ + --team "$team" \ + --name "$name" \ + --thread "$thread_id" \ + --app-server "$app_server" \ + --inline-inbox \ + >>"$log" 2>&1 & + exit 0 +} diff --git a/scripts/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh similarity index 91% rename from scripts/codex-bridge-launcher.sh rename to scripts/drivers/types/codex/codex-bridge-launcher.sh index e568c9a..d103b7a 100755 --- a/scripts/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -16,22 +16,22 @@ APP_SERVER="${3:?Missing app_server}" PARENT_PID="${4:?Missing parent_pid}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" RUN_DIR="$SKILL_DIR/run" -# shellcheck source=lib/hash.sh -source "$SCRIPT_DIR/lib/hash.sh" +# shellcheck source=../../../lib/hash.sh +source "$SCRIPT_DIR/../../../lib/hash.sh" PROJECT_HASH="$(printf '%s' "$PROJECT" | agmsg_sha1)" REQUEST_FILE="$RUN_DIR/codex-bridge-request.$PROJECT_HASH" -# shellcheck source=lib/node.sh -source "$SCRIPT_DIR/lib/node.sh" +# shellcheck source=../../../lib/node.sh +source "$SCRIPT_DIR/../../../lib/node.sh" NODE_BIN="$(agmsg_resolve_node)" TAB="$(printf '\t')" mkdir -p "$RUN_DIR" resolve_identity() { # prints "teamname" lines for the project's codex roles - "$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ + "$SCRIPT_DIR/../../../identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ | awk -v t="$TAB" 'NF >= 2 { print $1 t $2 }' \ | sort -u } diff --git a/scripts/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js similarity index 98% rename from scripts/codex-bridge.js rename to scripts/drivers/types/codex/codex-bridge.js index 126999f..91130e4 100755 --- a/scripts/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -8,8 +8,9 @@ const net = require("net"); const path = require("path"); const readline = require("readline"); -const SCRIPT_DIR = __dirname; -const SKILL_DIR = path.resolve(SCRIPT_DIR, ".."); +const SCRIPT_DIR = __dirname; // .../scripts/drivers/types/codex (codex siblings live here) +const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..", ".."); // skill root +const SCRIPTS_DIR = path.join(SKILL_DIR, "scripts"); // type-independent engine scripts (identities/inbox/send) const RUN_DIR = path.join(SKILL_DIR, "run"); // Git Bash on Windows cannot exec a .sh path directly — spawnSync of the script @@ -122,7 +123,7 @@ function parseArgs(argv) { } function runScript(script, args) { - const result = spawnSync(BASH_BIN, [path.join(SCRIPT_DIR, script), ...args], { + const result = spawnSync(BASH_BIN, [path.join(SCRIPTS_DIR, script), ...args], { cwd: SKILL_DIR, encoding: "utf8", }); @@ -816,8 +817,8 @@ class CodexBridge { } buildPrompt() { - const inbox = path.join(SCRIPT_DIR, "inbox.sh"); - const send = path.join(SCRIPT_DIR, "send.sh"); + const inbox = path.join(SCRIPTS_DIR, "inbox.sh"); + const send = path.join(SCRIPTS_DIR, "send.sh"); if (this.opts.inlineInbox) { return [ `agmsg delivered the following unread messages for ${this.identity.team}/${this.identity.name}:`, @@ -837,7 +838,7 @@ class CodexBridge { } readInboxForPrompt() { - const result = spawnSync(BASH_BIN, [path.join(SCRIPT_DIR, "inbox.sh"), this.identity.team, this.identity.name], { + const result = spawnSync(BASH_BIN, [path.join(SCRIPTS_DIR, "inbox.sh"), this.identity.team, this.identity.name], { cwd: this.opts.project, encoding: "utf8", }); diff --git a/scripts/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh similarity index 95% rename from scripts/codex-monitor.sh rename to scripts/drivers/types/codex/codex-monitor.sh index 3084a2f..db671d8 100755 --- a/scripts/codex-monitor.sh +++ b/scripts/drivers/types/codex/codex-monitor.sh @@ -8,10 +8,10 @@ set -euo pipefail # exposes CODEX_THREAD_ID to hooks. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" RUN_DIR="$SKILL_DIR/run" -# shellcheck source=lib/hash.sh -source "$SCRIPT_DIR/lib/hash.sh" +# shellcheck source=../../../lib/hash.sh +source "$SCRIPT_DIR/../../../lib/hash.sh" PROJECT="$(pwd)" SOCKET_PATH="" @@ -126,7 +126,7 @@ if ! port_alive "$PORT"; then fi SOCKET_URL="ws://127.0.0.1:$PORT" -"$SCRIPT_DIR/delivery.sh" set monitor codex "$PROJECT" >/dev/null +"$SCRIPT_DIR/../../../delivery.sh" set monitor codex "$PROJECT" >/dev/null export AGMSG_CODEX_BRIDGE=1 export AGMSG_CODEX_BRIDGE_APP_SERVER="$SOCKET_URL" diff --git a/scripts/codex-shim-install.sh b/scripts/drivers/types/codex/codex-shim-install.sh similarity index 100% rename from scripts/codex-shim-install.sh rename to scripts/drivers/types/codex/codex-shim-install.sh diff --git a/scripts/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh similarity index 97% rename from scripts/codex-shim.sh rename to scripts/drivers/types/codex/codex-shim.sh index f14daf6..ed2bd77 100755 --- a/scripts/codex-shim.sh +++ b/scripts/drivers/types/codex/codex-shim.sh @@ -106,7 +106,7 @@ first_non_option() { is_monitor_project() { local project="$1" local status - status="$("$SCRIPT_DIR/delivery.sh" status codex "$project" 2>/dev/null || true)" + status="$("$SCRIPT_DIR/../../../delivery.sh" status codex "$project" 2>/dev/null || true)" printf '%s\n' "$status" | grep -qx "mode: monitor" } diff --git a/templates/cmd.codex.md b/scripts/drivers/types/codex/template.md similarity index 91% rename from templates/cmd.codex.md rename to scripts/drivers/types/codex/template.md index bf9a92a..bd0f1c7 100644 --- a/templates/cmd.codex.md +++ b/scripts/drivers/types/codex/template.md @@ -63,7 +63,7 @@ Four possible outputs: - **Wait for the user's answer before proceeding.** Empty input means `1` (turn). - Map the chosen number to a mode (`1`→`turn`, `2`→`off`, `3`→`monitor`) and run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` - - If monitor is chosen, tell the user: "Codex monitor is a BETA that changes how `codex` starts — it installs a `codex` shim and needs `~/.agents/bin` first on PATH. If the output says `~/.agents/bin` is not on PATH, add `export PATH=\"$HOME/.agents/bin:$PATH\"` to your shell profile, restart the shell, then launch future sessions with normal `codex`. If shim installation was refused because `~/.agents/bin/codex` already exists, use `~/.agents/skills/__SKILL_NAME__/scripts/codex-monitor.sh` or resolve that command conflict. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" + - If monitor is chosen, tell the user: "Codex monitor is a BETA that changes how `codex` starts — it installs a `codex` shim and needs `~/.agents/bin` first on PATH. If the output says `~/.agents/bin` is not on PATH, add `export PATH=\"$HOME/.agents/bin:$PATH\"` to your shell profile, restart the shell, then launch future sessions with normal `codex`. If shim installation was refused because `~/.agents/bin/codex` already exists, use `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-monitor.sh` or resolve that command conflict. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" 6. Then check inbox for the newly joined team. @@ -143,7 +143,7 @@ If argument is "mode" (no further args): If argument starts with "mode" followed by a mode name (e.g. "mode monitor"): 1. Parse the mode. Codex supports `monitor` (beta bridge), `turn`, and `off` — reject `both` with: "Codex bridge beta supports `monitor`, `turn`, or `off`; `both` is not supported yet." 2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set codex "$(pwd)"` -3. If mode is `monitor`, tell the user: "Codex monitor beta is enabled. agmsg installs an optional `codex` shim automatically. If the output says `~/.agents/bin` is not on PATH, add `export PATH=\"$HOME/.agents/bin:$PATH\"` to your shell profile, restart the shell, then launch future sessions with normal `codex`. If shim installation was refused because `~/.agents/bin/codex` already exists, use `~/.agents/skills/__SKILL_NAME__/scripts/codex-monitor.sh` or resolve that command conflict. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" +3. If mode is `monitor`, tell the user: "Codex monitor beta is enabled. agmsg installs an optional `codex` shim automatically. If the output says `~/.agents/bin` is not on PATH, add `export PATH=\"$HOME/.agents/bin:$PATH\"` to your shell profile, restart the shell, then launch future sessions with normal `codex`. If shim installation was refused because `~/.agents/bin/codex` already exists, use `~/.agents/skills/__SKILL_NAME__/scripts/drivers/types/codex/codex-monitor.sh` or resolve that command conflict. The bridge starts on the **first turn** of a new Codex session (the SessionStart hook fires on your first message, not the moment Codex opens), so **restart your Codex session and send one message for monitor to take effect** — this already-running session stays unmonitored until it restarts. For more info: https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" If argument is "hook on" (legacy alias): 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set turn codex "$(pwd)"` diff --git a/scripts/drivers/types/codex/type.conf b/scripts/drivers/types/codex/type.conf new file mode 100644 index 0000000..00225fd --- /dev/null +++ b/scripts/drivers/types/codex/type.conf @@ -0,0 +1,12 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=codex +template=template.md +cli=codex +spawnable=yes +detect=CODEX_SANDBOX CODEX_THREAD_ID +detect_proc=codex codex-* +hooks_file=.codex/hooks.json +monitor=no +stop_output=json +hook_windows_wrap=yes +delivery_modes=monitor turn off diff --git a/scripts/watch-once.sh b/scripts/drivers/types/codex/watch-once.sh similarity index 92% rename from scripts/watch-once.sh rename to scripts/drivers/types/codex/watch-once.sh index 0ea1112..c3737b9 100755 --- a/scripts/watch-once.sh +++ b/scripts/drivers/types/codex/watch-once.sh @@ -46,14 +46,14 @@ case "$INTERVAL" in ''|*[!0-9]*) echo "watch-once: --interval must be a whole nu [ "$INTERVAL" -gt 0 ] || INTERVAL=1 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -source "$SCRIPT_DIR/lib/storage.sh" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +source "$SCRIPT_DIR/../../../lib/storage.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/actas-lock.sh" +source "$SCRIPT_DIR/../../../lib/actas-lock.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/resolve-project.sh" +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/lib/subscription.sh" +source "$SCRIPT_DIR/../../../lib/subscription.sh" PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE")" DB="$(agmsg_db_path)" diff --git a/scripts/drivers/types/copilot/_delivery.sh b/scripts/drivers/types/copilot/_delivery.sh new file mode 100644 index 0000000..b434df9 --- /dev/null +++ b/scripts/drivers/types/copilot/_delivery.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# copilot delivery plug — JSON hooks file (.github/hooks/agmsg.json). Only turn|off +# reach this function: copilot's manifest declares delivery_modes=turn off, so +# delivery.sh's central gate rejects monitor/both before apply runs (and before +# any file is touched, so a fat-fingered 'monitor' can't wipe a working turn +# hook). Uses resolve_hooks_file + SKILL_DIR from delivery.sh's sourced context. +agmsg_delivery_apply() { + local type="$1" + local project="$2" + local mode="$3" + local hooks_file + hooks_file=$(resolve_hooks_file "$type" "$project") + + # Strip first so re-applying turn is an idempotent rewrite and turn->off + # cleanly removes the file. + rm -f "$hooks_file" + + if [ "$mode" = "turn" ]; then + mkdir -p "$(dirname "$hooks_file")" + local cmd="'$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project'" + # json_quote handles JSON-string escaping for arbitrary command strings + # (project paths may contain JSON-special chars). + local cmd_json + cmd_json=$(agmsg_sqlite_mem "SELECT json_quote('$(printf '%s' "$cmd" | sed "s/'/''/g")');") + # Use PascalCase 'Stop' trigger so the input payload field names match + # the snake_case form (session_id) that check-inbox.sh already parses. + cat < "$hooks_file" +{ + "version": 1, + "hooks": { + "Stop": [ + { + "type": "command", + "bash": $cmd_json, + "timeoutSec": 30 + } + ] + } +} +EOF + fi +} +agmsg_delivery_status() { rulefile_status "$@"; } diff --git a/templates/cmd.copilot.md b/scripts/drivers/types/copilot/template.md similarity index 100% rename from templates/cmd.copilot.md rename to scripts/drivers/types/copilot/template.md diff --git a/scripts/drivers/types/copilot/type.conf b/scripts/drivers/types/copilot/type.conf new file mode 100644 index 0000000..361b89a --- /dev/null +++ b/scripts/drivers/types/copilot/type.conf @@ -0,0 +1,8 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=copilot +template=template.md +detect=explicit +hooks_file=.github/hooks/agmsg.json +monitor=no +stop_output=json +delivery_modes=turn off diff --git a/scripts/drivers/types/gemini/_delivery.sh b/scripts/drivers/types/gemini/_delivery.sh new file mode 100644 index 0000000..6232dee --- /dev/null +++ b/scripts/drivers/types/gemini/_delivery.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# gemini delivery plug — rule-file integration (markdown rules file). +# +# Sourced by delivery.sh (in its function context) to override the default +# agmsg_delivery_apply. rulefile_apply is provided by +# scripts/lib/delivery-rulefile.sh, which delivery.sh sources first. +agmsg_delivery_apply() { rulefile_apply "$@"; } +agmsg_delivery_status() { rulefile_status "$@"; } diff --git a/templates/cmd.gemini.md b/scripts/drivers/types/gemini/template.md similarity index 100% rename from templates/cmd.gemini.md rename to scripts/drivers/types/gemini/template.md diff --git a/scripts/drivers/types/gemini/type.conf b/scripts/drivers/types/gemini/type.conf new file mode 100644 index 0000000..795f078 --- /dev/null +++ b/scripts/drivers/types/gemini/type.conf @@ -0,0 +1,8 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=gemini +template=template.md +detect=GEMINI_API_KEY GOOGLE_GEMINI_CLI +detect_proc=gemini gemini-* +hooks_file=.agent/rules/agmsg.md +monitor=no +delivery_modes=monitor turn both off diff --git a/scripts/drivers/types/hermes/_delivery.sh b/scripts/drivers/types/hermes/_delivery.sh new file mode 100644 index 0000000..d51a0ac --- /dev/null +++ b/scripts/drivers/types/hermes/_delivery.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# hermes delivery plug — manual inbox checks only. +# +# Hermes has no agmsg automatic delivery hook: there is no SessionStart / Stop +# equivalent to write, so the only valid mode is `off` (enforced by +# delivery_modes=off in the manifest, which the central gate in delivery.sh +# checks). These overrides keep apply / status / teardown from touching a hook +# file or another agent type's watchers. Sourced into delivery.sh's context. + +# Nothing to write — manual-only, so no hooks_file is resolved or created. +agmsg_delivery_apply() { :; } + +# No hook file to read; the mode is always off. +agmsg_delivery_status() { echo "mode: off"; } + +# No watcher or bridge of our own. Do NOT fall through to the default teardown +# (which stops this project's watch.sh) — another agent type may hold a live +# watcher on the same project, and a hermes `set off` must not disturb it. +agmsg_delivery_on_disable() { + echo "Hermes has no agmsg automatic delivery hook; manual inbox checks only." +} diff --git a/scripts/drivers/types/hermes/template.md b/scripts/drivers/types/hermes/template.md new file mode 100644 index 0000000..f6f4c1b --- /dev/null +++ b/scripts/drivers/types/hermes/template.md @@ -0,0 +1,124 @@ +--- +name: __SKILL_NAME__ +description: Cross-agent messaging via SQLite. Send messages between Claude Code, Codex, Gemini CLI, Hermes Agent, and other agents. No daemon, no network, no dependencies beyond bash and sqlite3. +--- + +Hermes Agent skill for agmsg cross-agent messaging. **IMPORTANT: Always use the provided scripts. NEVER directly read or edit config files, DB, or team data. There is NO register.sh — use join.sh to join a team.** + +## Identity + +If you already know your AGENT and TEAMS from a previous `/__SKILL_NAME__` use in this session, skip to **Execute** below. + +Otherwise, run: `~/.agents/skills/__SKILL_NAME__/scripts/whoami.sh "$(pwd)" hermes` + +Four possible outputs: + +**A) Single identity:** +`agent= teams= type=hermes project=` +→ Remember AGENT and TEAMS, then go to **Execute**. + +**B) Multiple identities:** +`multiple=true agents= teams= type=hermes project=` +→ Ask the user which agent name to use for this session, then go to **Execute**. + +**C) Not in a team:** +`not_joined=true available_teams=` (or `available_teams=none`) +→ Show the user the available teams from the output, then: + + > **First-time setup required.** + > Joining a team so this agent can send and receive messages. + > - **Team name**: a group of agents that can message each other (available: ) + > - **Agent name**: this agent's identity within the team + + 1. Ask: "Enter a team name (joins existing or creates new)" + 2. Ask: "Enter a name for this agent" + 3. **You MUST use join.sh** — run: `~/.agents/skills/__SKILL_NAME__/scripts/join.sh hermes "$(pwd)"` + 4. Show the result and explain: + + > **Joined!** You can now use `/__SKILL_NAME__` to check and send messages. + > - ask to check inbox — check unread messages + > - ask `send ` — send a message + > - ask `team` — list team members + > - ask `history` — message history + + 5. Hermes has no agmsg automatic delivery hook. Set manual delivery explicitly: + `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set off hermes "$(pwd)"` + + 6. Then check inbox for the newly joined team. + +**D) Suggestions for reuse:** +`suggest=true agents= teams= type=hermes project= available_teams=` +→ No exact registration exists for this project, but there are same-type agent names registered elsewhere. + + 1. Show the suggested agent names to the user. + 2. Ask whether to reuse one of those names or choose a new one. + 3. Ask for the team name to join (existing or new). + 4. Run: `~/.agents/skills/__SKILL_NAME__/scripts/join.sh hermes "$(pwd)"` + 5. Then continue with the normal post-join flow above. + +## Execute + +**Only use scripts in `~/.agents/skills/__SKILL_NAME__/scripts/` — do not read or modify files under `teams/` or `db` directly.** + +**If no arguments provided (DEFAULT action — always do this when the command is invoked without arguments):** +1. **IMMEDIATELY** run inbox check for each TEAM: `~/.agents/skills/__SKILL_NAME__/scripts/inbox.sh $TEAM $AGENT` +2. Do NOT ask the user what to do — just run the inbox check. +3. If there are messages, read and respond appropriately. To reply: + `~/.agents/skills/__SKILL_NAME__/scripts/send.sh $TEAM $AGENT ""` + +If argument is "history": +1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/history.sh $TEAM $AGENT` + +If argument is "team": +1. For each TEAM, run: `~/.agents/skills/__SKILL_NAME__/scripts/team.sh $TEAM` + +If argument starts with "send" (e.g. "send misaki check the server"): +1. Parse target agent and message from the arguments +2. Determine which team the target agent belongs to, then run: + `~/.agents/skills/__SKILL_NAME__/scripts/send.sh $TEAM $AGENT ""` + +If argument is "config": +1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/config.sh show` +2. Show the output to the user. + +If argument starts with "config set" (e.g. "config set hook.check_interval 30"): +1. Parse key and value from the arguments. +2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/config.sh set ` + +If argument starts with "actas" followed by an agent name (e.g. "actas alice"): +1. Parse the new role name. +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/identities.sh "$(pwd)" hermes` to see whether the role is already registered for this (project, type). +3. If the name does not appear in the output, join under the existing team. For a single team, run `~/.agents/skills/__SKILL_NAME__/scripts/join.sh hermes "$(pwd)"`. For multiple teams, ask the user which team to join the new role into. +4. Set the session's active FROM to `` for every `send.sh` call until another `actas`. +5. Tell the user: "Now acting as ``. Sends will use `` as the from agent." + +If argument starts with "drop" followed by an agent name (e.g. "drop alice"): +1. Parse the role name. +2. Run `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" hermes ` to remove that role's registration. +3. If the session's active FROM was ``, clear that state. +4. Tell the user: "Dropped role `` from this project." + +If argument starts with "spawn" (e.g. "spawn claude-code alice", "spawn codex reviewer --window", "spawn hermes reviewer"): +1. Parse `` (must be `claude-code`, `codex`, or `hermes`), ``, and any options (`--project`, `--team`, `--window`, `--split h|v`, `--terminal`, `--no-wait`, `--ready-timeout `). +2. Run: `~/.agents/skills/__SKILL_NAME__/scripts/spawn.sh --project "$(pwd)" [options]` +3. Show the script's output. A spawned hermes session takes the actas role `` via the boot prompt and uses Hermes's own default profile. + +If argument is "mode" (no further args): +1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh status hermes "$(pwd)"` +2. Show the output to the user. + +If argument starts with "mode" followed by a mode name (e.g. "mode off"): +1. Parse the mode. Hermes supports only `off`. +2. If the requested mode is `off`, run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set off hermes "$(pwd)"` +3. If the requested mode is `monitor`, `both`, or `turn`, do not run a command; tell the user: "Hermes has no agmsg automatic delivery hook; only `off` mode is supported." + +If argument is "hook on" (legacy alias): +1. Tell the user: "Hermes has no agmsg automatic delivery hook; use manual inbox checks." + +If argument is "hook off" (legacy alias): +1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/delivery.sh set off hermes "$(pwd)"` +2. Tell the user: "Delivery mode set to 'off'." + +If argument is "reset": +1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/reset.sh "$(pwd)" hermes` +2. Tell the user the result. diff --git a/scripts/drivers/types/hermes/type.conf b/scripts/drivers/types/hermes/type.conf new file mode 100644 index 0000000..adc4ff6 --- /dev/null +++ b/scripts/drivers/types/hermes/type.conf @@ -0,0 +1,8 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=hermes +template=template.md +cli=hermes +spawnable=yes +detect=explicit +monitor=no +delivery_modes=off diff --git a/scripts/drivers/types/opencode/_delivery.sh b/scripts/drivers/types/opencode/_delivery.sh new file mode 100644 index 0000000..6c071ed --- /dev/null +++ b/scripts/drivers/types/opencode/_delivery.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# opencode delivery plug — markdown rule-file. Only turn|off reach this function: +# opencode's manifest declares delivery_modes=turn off, so delivery.sh's central +# gate rejects monitor/both before apply runs. Uses resolve_hooks_file + SKILL_DIR +# from delivery.sh's sourced context. +agmsg_delivery_apply() { + local type="$1" + local project="$2" + local mode="$3" + local rule_file + rule_file=$(resolve_hooks_file "$type" "$project") + + rm -f "$rule_file" + + if [ "$mode" = "turn" ]; then + mkdir -p "$(dirname "$rule_file")" + cat < "$rule_file" +# agmsg Integration Rule + +## PostToolUse +After each tool call, automatically check the agmsg inbox for unread messages. +- Command: '$SKILL_DIR/scripts/check-inbox.sh' '$type' '$project' +EOF + fi +} +agmsg_delivery_status() { rulefile_status "$@"; } diff --git a/templates/cmd.opencode.md b/scripts/drivers/types/opencode/template.md similarity index 100% rename from templates/cmd.opencode.md rename to scripts/drivers/types/opencode/template.md diff --git a/scripts/drivers/types/opencode/type.conf b/scripts/drivers/types/opencode/type.conf new file mode 100644 index 0000000..e3c8359 --- /dev/null +++ b/scripts/drivers/types/opencode/type.conf @@ -0,0 +1,7 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=opencode +template=template.md +detect_proc=opencode opencode-* +hooks_file=.opencode/rules/agmsg.md +monitor=no +delivery_modes=turn off diff --git a/scripts/hook.sh b/scripts/hook.sh deleted file mode 100755 index ac52e5b..0000000 --- a/scripts/hook.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Backward-compatible alias for delivery.sh. -# -# Usage: hook.sh on → delivery.sh set turn ... -# hook.sh off → delivery.sh set off ... -# -# hook.sh predates the delivery-mode redesign. Prefer delivery.sh directly: -# delivery.sh set - -ACTION="${1:?Usage: hook.sh on|off }" -shift - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -echo "agmsg: hook.sh is deprecated; use 'delivery.sh set ' or '/agmsg mode ' instead." >&2 - -case "$ACTION" in - on) exec "$SCRIPT_DIR/delivery.sh" set turn "$@" ;; - off) exec "$SCRIPT_DIR/delivery.sh" set off "$@" ;; - *) echo "Unknown action: $ACTION (use on|off)" >&2; exit 1 ;; -esac diff --git a/scripts/init-db.sh b/scripts/internal/init-db.sh similarity index 97% rename from scripts/init-db.sh rename to scripts/internal/init-db.sh index 4299ca8..940a63a 100755 --- a/scripts/init-db.sh +++ b/scripts/internal/init-db.sh @@ -2,7 +2,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "$SCRIPT_DIR/lib/storage.sh" +source "$SCRIPT_DIR/../lib/storage.sh" DB="$(agmsg_db_path)" DB_DIR="$(dirname "$DB")" mkdir -p "$DB_DIR" diff --git a/scripts/join.sh b/scripts/join.sh index ba8015d..8dfa65e 100755 --- a/scripts/join.sh +++ b/scripts/join.sh @@ -7,19 +7,22 @@ set -euo pipefail TEAM="${1:?Usage: join.sh }" AGENT_ID="${2:?Missing agent_id}" -AGENT_TYPE="${3:?Missing type (claude-code | codex)}" +AGENT_TYPE="${3:?Missing type (a registered type under scripts/drivers/types//)}" PROJECT_PATH="${4:?Missing project_path}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" + # Reject unknown agent types — the rest of agmsg (delivery.sh, -# session-start.sh, identities.sh lookups) only supports the values listed -# here. Allowing arbitrary strings silently mis-registers an agent and -# makes monitor mode fail with a confusing "no joined teams" message. -case "$AGENT_TYPE" in - claude-code|codex|gemini|antigravity|copilot|opencode) ;; - *) echo "Unknown agent type: '$AGENT_TYPE' (supported: claude-code, codex, gemini, antigravity, copilot, opencode)" >&2; exit 1 ;; -esac +# session-start.sh, identities.sh lookups) only supports registered types +# (scripts/drivers/types//type.conf). Allowing arbitrary strings silently mis-registers an +# agent and makes monitor mode fail with a confusing "no joined teams" message. +if ! agmsg_is_known_type "$AGENT_TYPE"; then + echo "Unknown agent type: '$AGENT_TYPE' (supported: $(agmsg_known_types | sort -u | paste -sd, - | sed 's/,/, /g'))" >&2 + exit 1 +fi -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TEAMS_DIR="$SCRIPT_DIR/../teams" diff --git a/scripts/lib/delivery-rulefile.sh b/scripts/lib/delivery-rulefile.sh new file mode 100644 index 0000000..3aa06a3 --- /dev/null +++ b/scripts/lib/delivery-rulefile.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Shared "rule-file" delivery behavior. +# +# Some agent types integrate by writing a small markdown rules file that tells +# the agent to poll the agmsg inbox after each tool call (gemini, antigravity, +# opencode). Their per-type plug (scripts/drivers/types//_delivery.sh) is then a one-line +# delegation to rulefile_apply. +# +# Runs in delivery.sh's sourced context: resolve_hooks_file and SKILL_DIR are +# provided by the caller (delivery.sh sources this lib and the type plug). +rulefile_apply() { + local type="$1" project="$2" mode="$3" + local rule_file + rule_file="$(resolve_hooks_file "$type" "$project")" + + # Always start clean; each mode rewrites (or leaves absent) the rule file. + rm -f "$rule_file" + + case "$mode" in + turn|both) + mkdir -p "$(dirname "$rule_file")" + cat > "$rule_file" <&2 + rulefile_apply "$type" "$project" turn + ;; + off) + : # rule file already removed + ;; + esac +} + +# Status for rule-file types: the rule file's presence is the whole state — +# present means turn-mode is active, absent means off (no monitor for these). +rulefile_status() { + local type="$1" project="$2" + local rule_file + rule_file="$(resolve_hooks_file "$type" "$project")" + if [ -f "$rule_file" ]; then echo "mode: turn"; else echo "mode: off"; fi +} diff --git a/scripts/lib/driver-registry.sh b/scripts/lib/driver-registry.sh new file mode 100644 index 0000000..6bdda87 --- /dev/null +++ b/scripts/lib/driver-registry.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Driver registry — axis-generic discovery + trust policy. +# +# agmsg's pluggable units are "drivers" grouped by axis (ADR 0001 / docs/spec/ +# driver-interface.md). The agent-type registry (type-registry.sh) is the first +# consumer (axis = "types"); storage and delivery axes reuse the same machinery. +# +# This lib knows NOTHING about a given axis's internal layout (types use a +# directory with type.conf; another axis may differ). It only provides: +# - the ordered search BASES (in-tree built-ins, then external plugin dirs) +# - the TRUST policy (which external drivers the user opted into) +# Each axis facade enumerates `//...` itself and gates externals with +# agmsg_driver_is_trusted. +# +# Search bases, in priority order (later overrides earlier among ELIGIBLE ones): +# 1. /scripts/drivers in-tree built-ins — always trusted +# 2. /plugins default external plugin dir (install_dir/plugins) +# 3. each dir in $AGMSG_PLUGIN_DIRS ':'-separated extra external dirs +# +# SECURITY: external drivers are shell code that runs with the user's privileges. +# They are NEVER loaded unless explicitly opted into (`agmsg plugin trust`), so an +# unexpected drop-in cannot execute. An untrusted external driver that is present +# is ignored (a built-in of the same name still resolves); callers may warn. +# +# Safe under `set -u`: every env read is guarded. + +# Resolve THIS lib's dir at source time (robust to later subshell/relative cwd). +_AGMSG_DRIVER_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" + +# = up two from scripts/lib/. +_agmsg_driver_root() { + cd "$_AGMSG_DRIVER_LIB_DIR/../.." 2>/dev/null && pwd +} + +# Echo the search bases as "\t" lines, in priority order. is +# "builtin" (in-tree, always trusted) or "external" (requires opt-in). +agmsg_driver_bases() { + local root + root="$(_agmsg_driver_root)" || return 0 + [ -n "$root" ] || return 0 + printf 'builtin\t%s\n' "$root/scripts/drivers" + printf 'external\t%s\n' "$root/plugins" + # AGMSG_PLUGIN_DIRS: ':'-separated extra external bases (override last). + local IFS=: d + for d in ${AGMSG_PLUGIN_DIRS:-}; do + [ -n "$d" ] && printf 'external\t%s\n' "$d" + done +} + +# Path to the opt-in allowlist. One trusted driver per line: "/\t". +# Lives under db/ (preserved across --update installs, like config.yaml). A plain +# TSV — not config.yaml — because driver identities contain '/' which the YAML +# key parser does not handle, and append/grep/remove are trivial here. +agmsg_driver_trustfile() { + local root + root="$(_agmsg_driver_root)" || return 1 + printf '%s\n' "$root/db/trusted-plugins" +} + +# 0 if the external driver / at was opted into (exact path +# match — a trusted name pointing elsewhere is NOT honored, so swapping the dir +# under a trusted name does not silently activate new code). +agmsg_driver_is_trusted() { + local axis="$1" name="$2" path="$3" tf + tf="$(agmsg_driver_trustfile)" || return 1 + [ -f "$tf" ] || return 1 + grep -qxF "$(printf '%s/%s\t%s' "$axis" "$name" "$path")" "$tf" +} + +# Record an opt-in for / at . Idempotent. +agmsg_driver_trust() { + local axis="$1" name="$2" path="$3" tf line + tf="$(agmsg_driver_trustfile)" || return 1 + mkdir -p "$(dirname "$tf")" + line="$(printf '%s/%s\t%s' "$axis" "$name" "$path")" + [ -f "$tf" ] && grep -qxF "$line" "$tf" && return 0 + printf '%s\n' "$line" >> "$tf" +} + +# Remove every opt-in for / (any path). +agmsg_driver_untrust() { + local axis="$1" name="$2" tf tmp + tf="$(agmsg_driver_trustfile)" || return 1 + [ -f "$tf" ] || return 0 + tmp="$(mktemp "${TMPDIR:-/tmp}/agmsg-trust.XXXXXX")" + grep -vE "^$(printf '%s/%s' "$axis" "$name" | sed 's/[][\.*^$/]/\\&/g') " "$tf" > "$tmp" 2>/dev/null || true + mv "$tmp" "$tf" +} diff --git a/scripts/lib/hooks-json.sh b/scripts/lib/hooks-json.sh new file mode 100644 index 0000000..97d3802 --- /dev/null +++ b/scripts/lib/hooks-json.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# hooks-json.sh — JSON/SQLite primitives for editing an agent's settings hooks file. +# +# These are the low-level read-modify-write helpers that delivery.sh uses to add +# and remove agmsg-owned hook entries from a settings.json-shaped file. They are +# pure JSON manipulation built on sqlite3's json1 + readfile()/writefile(), with +# no knowledge of delivery modes or agent-type dispatch (that lives in +# delivery.sh). Split out of delivery.sh so the gnarly sqlite/JSON layer — and +# its accumulated bug-fix guards (#95 E2BIG, #143/#102 control-byte escaping, +# #162 byte-count validation, #134 JSON escaping) — can be read and tested on +# its own. +# +# Sourced by delivery.sh AFTER it defines SKILL_NAME (used to detect +# agmsg-owned entries); the existing lib convention is for sourced modules to +# reference caller-set globals rather than re-resolve them. + +sql_readfile_path() { + local path="$1" + if command -v cygpath >/dev/null 2>&1; then + path=$(cygpath -w "$path" 2>/dev/null || printf '%s' "$path") + fi + printf '%s' "$path" | sed "s/'/''/g" +} + +# Strip any agmsg-owned hook entries from in the JSON at . An +# entry is "agmsg-owned" when one of its inner hooks references a path under +# our skill directory. Result is written back to atomically. +# +# Reads the settings via sqlite3's readfile() rather than interpolating the +# file's contents into the SQL string. The old in-memory chain embedded the +# settings blob 6× into a single sqlite3 argv element; on Linux that hits +# the per-arg MAX_ARG_STRLEN cap (131072 bytes) once the settings file +# crosses ~21 KB, so `delivery.sh set` failed with E2BIG (see #95). Using +# readfile() keeps the file off the argv entirely. +strip_agmsg_event_file() { + local path="$1" + local event="$2" + local sql_path + sql_path=$(sql_readfile_path "$path") + local tmp tmp_sql + tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") + tmp_sql=$(sql_readfile_path "$tmp") + # Write the result with writefile() rather than redirecting sqlite3's CLI + # output. On strict sqlite3 builds (>= 3.50, shipped on Windows) the CLI + # renders control bytes — e.g. a CR that rode in on a CRLF settings file — + # using caret notation ("^M"), corrupting the JSON so the next read fails + # with "malformed JSON" (#143/#138, same root cause as #102). writefile() + # emits the bytes verbatim. See also strip's readfile() (#95). + # Validate writefile()'s result, not just sqlite3's exit code. writefile() + # returns the byte count written and yields NULL on a failed write (e.g. an + # unwritable tmp dir) — but sqlite3 still exits 0, so an exit-code-only check + # would mv an empty/partial tmp over the original. Compare the bytes written + # to the content's byte length (CAST AS BLOB so multibyte content isn't + # miscounted by character-based length()); anything but an exact match fails. + # Guard contributed in #162 (kevinsj15). + local wrote + wrote=$(agmsg_sqlite_mem " + WITH src AS (SELECT readfile('$sql_path') AS j), + out AS (SELECT coalesce(CASE + WHEN json_extract(src.j, '\$.hooks.$event') IS NULL THEN + src.j + WHEN (SELECT count(*) FROM json_each(json_extract(src.j, '\$.hooks.$event')) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM json_each(json_extract(s.value, '\$.hooks')) AS h + WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 + )) = 0 THEN + json_remove(src.j, '\$.hooks.$event') + ELSE + json_set(src.j, '\$.hooks.$event', + (SELECT json_group_array(json(s.value)) + FROM json_each(json_extract(src.j, '\$.hooks.$event')) AS s + WHERE NOT EXISTS ( + SELECT 1 FROM json_each(json_extract(s.value, '\$.hooks')) AS h + WHERE instr(json_extract(h.value, '\$.command'), '$SKILL_NAME') > 0 + )) + ) + END, '') AS blob FROM src) + SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; + ") || { rm -f "$tmp"; return 1; } + [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } + mv "$tmp" "$path" +} + +# Wrap a POSIX shell command so Codex's Windows runner executes it through Git +# Bash. On native Windows, Codex runs each hook command via PowerShell, which +# cannot execute a bare POSIX ".sh" path, so the hook exits non-zero. Codex hook +# config supports a "commandWindows" key that takes precedence on Windows. +windows_wrap() { + local posix_cmd="$1" + local bash_cmd_ps + bash_cmd_ps=$(printf '%s' "$posix_cmd" | sed "s/'/''/g") + printf "\$b=\$env:GIT_BASH; if (-not \$b) { \$b=\$env:AGMSG_BASH }; if (-not \$b) { \$b='C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe' }; & \$b -lc '%s'" "$bash_cmd_ps" +} + +# Append a single entry of the form {"matcher":"","hooks":[{"type":"command","command":""}]} +# to .hooks. in the JSON at , creating arrays/objects as needed. +# When the 4th arg is "yes" the entry also carries a "commandWindows" so the hook +# runs on native Windows; otherwise it is omitted. This layer stays type-agnostic +# — the caller (delivery.sh) decides from the type manifest whether to wrap. +# Writes the result back to . As with strip_agmsg_event_file, the settings +# are read via readfile() rather than via argv (#95). +add_event_entry_file() { + local path="$1" + local event="$2" + local cmd="$3" + local windows_wrap="${4:-}" + local sql_path + sql_path=$(sql_readfile_path "$path") + + # Build the entry with SQLite's own json_object()/json_array() so SQLite does + # every JSON-level escape. Raw values go in as ordinary SQL string literals + # (single quotes doubled) — the only escaping this layer needs. Hand-building + # the JSON string instead (and only escaping the codex commandWindows) left + # the "command" value's embedded " and ' unescaped, producing "malformed + # JSON" on tricky project paths and on native Windows sqlite builds (#134). + local cmd_lit + cmd_lit=$(printf '%s' "$cmd" | sed "s/'/''/g") + local hook_obj="json_object('type','command','command','$cmd_lit'" + if [ "$windows_wrap" = "yes" ]; then + local cw cw_lit + cw=$(windows_wrap "$cmd") + cw_lit=$(printf '%s' "$cw" | sed "s/'/''/g") + hook_obj="$hook_obj,'commandWindows','$cw_lit'" + fi + hook_obj="$hook_obj)" + local entry_sql="json_object('matcher','','hooks',json_array($hook_obj))" + + local tmp tmp_sql + tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") + tmp_sql=$(sql_readfile_path "$tmp") + # writefile() instead of CLI redirect — see strip_agmsg_event_file for why + # (strict sqlite3 caret-escapes control bytes in CLI output, #143/#102). + # Validate writefile()'s byte count vs the content length — see + # strip_agmsg_event_file for why the exit code alone is insufficient (#162). + local wrote + wrote=$(agmsg_sqlite_mem " + WITH base AS ( + SELECT CASE WHEN json_extract(readfile('$sql_path'), '\$.hooks') IS NULL + THEN json_set(readfile('$sql_path'), '\$.hooks', json('{}')) + ELSE readfile('$sql_path') END AS s + ), + out AS (SELECT CASE + WHEN json_extract(s, '\$.hooks.$event') IS NULL THEN + json_set(s, '\$.hooks.$event', json_array($entry_sql)) + ELSE + json_set(s, '\$.hooks.$event', + (SELECT json_group_array(json(v.value)) FROM ( + SELECT value FROM json_each(json_extract(s, '\$.hooks.$event')) + UNION ALL + SELECT $entry_sql + ) v) + ) + END AS blob FROM base) + SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; + ") || { rm -f "$tmp"; return 1; } + [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } + mv "$tmp" "$path" +} + +# Drop the entire .hooks object if it ended up empty after stripping. Reads +# and writes via readfile() — see strip_agmsg_event_file for the +# rationale (#95). +prune_empty_hooks_file() { + local path="$1" + local sql_path + sql_path=$(sql_readfile_path "$path") + local tmp tmp_sql + tmp=$(mktemp "${TMPDIR:-/tmp}/agmsg.XXXXXX") + tmp_sql=$(sql_readfile_path "$tmp") + # writefile() instead of CLI redirect — see strip_agmsg_event_file (#143/#102). + # Validate writefile()'s byte count vs the content length — see + # strip_agmsg_event_file for why the exit code alone is insufficient (#162). + local wrote + wrote=$(agmsg_sqlite_mem " + WITH src AS (SELECT readfile('$sql_path') AS j), + out AS (SELECT coalesce(CASE + WHEN json_extract(src.j, '\$.hooks') IS NULL THEN src.j + WHEN (SELECT count(*) FROM json_each(json_extract(src.j, '\$.hooks'))) = 0 THEN + json_remove(src.j, '\$.hooks') + ELSE src.j + END, '') AS blob FROM src) + SELECT writefile('$tmp_sql', blob) = length(CAST(blob AS BLOB)) FROM out; + ") || { rm -f "$tmp"; return 1; } + [ "$wrote" = "1" ] || { rm -f "$tmp"; return 1; } + mv "$tmp" "$path" +} diff --git a/scripts/lib/type-registry.sh b/scripts/lib/type-registry.sh new file mode 100644 index 0000000..83fe679 --- /dev/null +++ b/scripts/lib/type-registry.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Agent-type registry. +# +# Agent types are discovered from `scripts/drivers/types//type.conf` manifests instead of +# hardcoded whitelists, so a type (and its template / delivery / session-start / +# spawn behavior) can be added by dropping a directory — including by an external +# add-on outside the agmsg tree. +# +# IMPORTANT — manifests are read-only `key=value` DATA and are NEVER `source`d. +# A small per-key reader is used, so a third-party add-on's manifest cannot +# execute code. Multi-value keys are space-separated. +# +# Discovery + trust are delegated to driver-registry.sh (this is the "types" +# axis). Search bases are /scripts/drivers (built-in), /plugins, and +# $AGMSG_PLUGIN_DIRS. External drivers must be opted into (`agmsg plugin trust`); +# untrusted drop-ins are ignored. Later bases override earlier ones among +# eligible candidates, so an opted-in plugin can shadow a built-in. +# +# Safe under `set -u`: every env read is guarded. + +# Resolve THIS lib's directory at SOURCE time. BASH_SOURCE inside a later +# function call — especially within a command-substitution subshell, or when the +# lib was sourced via a relative path from a different cwd — can resolve against +# the wrong directory; capturing it once here is robust however the registry is +# queried later. +_AGMSG_REGISTRY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" + +# Axis-generic bases + trust policy. +# shellcheck disable=SC1091 +. "$_AGMSG_REGISTRY_LIB_DIR/driver-registry.sh" + +# Warn (once per process, on stderr) about any untrusted external 'types' driver +# that is present. An unexpected drop-in is a potential attack, so it is ignored +# until opted into; this tells the user it exists and how to trust it. +_agmsg_type_warn_untrusted() { + [ -n "${_AGMSG_TYPE_WARNED:-}" ] && return 0 + _AGMSG_TYPE_WARNED=1 + local kind base dir name + while IFS=$'\t' read -r kind base; do + [ "$kind" = external ] && [ -d "$base/types" ] || continue + for dir in "$base"/types/*/; do + [ -f "${dir}type.conf" ] || continue + name="$(basename "$dir")" + agmsg_driver_is_trusted types "$name" "${dir%/}" && continue + printf "agmsg: external plugin 'types/%s' found at %s but not trusted (ignored).\n Opt in if you put it there intentionally: agmsg plugin trust types/%s\n" \ + "$name" "${dir%/}" "$name" >&2 + done + done </type.conf, or return 1. Later bases override +# earlier ones among ELIGIBLE candidates: built-ins are always eligible; external +# drivers only once opted into. Untrusted external candidates are skipped. +agmsg_type_dir() { + local want="$1" kind base dir chosen="" + while IFS=$'\t' read -r kind base; do + dir="$base/types/$want" + [ -f "$dir/type.conf" ] || continue + if [ "$kind" = builtin ] || agmsg_driver_is_trusted types "$want" "$dir"; then + chosen="$dir" + fi + done < is a known type. +agmsg_is_known_type() { + local want="$1" t + while IFS= read -r t; do + [ "$t" = "$want" ] && return 0 + done </type.conf. Usage: +# agmsg_type_get [default] +# Reads (never sources) the manifest; strips surrounding quotes/space. +agmsg_type_get() { + local name="$1" key="$2" def="${3:-}" dir line val + dir="$(agmsg_type_dir "$name")" || { printf '%s\n' "$def"; return 0; } + # `|| true` so a no-match grep (exit 1) does not, under set -e + pipefail, + # abort the assignment before the default-return branch below is reached. + line="$( { grep -E "^[[:space:]]*${key}[[:space:]]*=" "$dir/type.conf" 2>/dev/null || true; } | head -1)" + if [ -z "$line" ]; then + printf '%s\n' "$def" + return 0 + fi + val="${line#*=}" + # trim leading/trailing whitespace + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + # strip one pair of surrounding double quotes if present + case "$val" in + \"*\") val="${val#\"}"; val="${val%\"}" ;; + esac + printf '%s\n' "$val" +} + +# Echo the absolute path to 's SKILL command template, resolved from the +# manifest `template=` key relative to the type's own directory +# (scripts/drivers/types//template.md). Returns 1 if the type or its template= key is +# unknown. template= is a type-dir-relative filename; reject absolute paths or +# traversal so a third-party manifest can't redirect reads outside its type dir +# (mirrors resolve_hooks_file's guard in delivery.sh). +agmsg_type_template_path() { + local name="$1" dir rel + dir="$(agmsg_type_dir "$name")" || return 1 + rel="$(agmsg_type_get "$name" template)" + [ -n "$rel" ] || return 1 + case "$rel" in + /*|*..*) echo "Invalid template for $name: $rel" >&2; return 1 ;; + esac + printf '%s\n' "$dir/$rel" +} + +# Comma-or-space list helper: 0 if is in the space-separated 's . +agmsg_type_has() { + local name="$1" key="$2" want="$3" tok + for tok in $(agmsg_type_get "$name" "$key"); do + [ "$tok" = "$want" ] && return 0 + done + return 1 +} diff --git a/scripts/plugin.sh b/scripts/plugin.sh new file mode 100755 index 0000000..3bde2bd --- /dev/null +++ b/scripts/plugin.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# agmsg plugin — manage trust for EXTERNAL drivers (axes: types, and later +# storage / delivery). External drivers are shell code that runs with your +# privileges, so the registry ignores them until you opt in here. +# +# Usage: +# plugin.sh list # discovered drivers + trust state +# plugin.sh trust # opt into an external driver +# plugin.sh untrust # revoke +# +# is "/" (e.g. types/codex) or a bare "". A bare name +# matches across axes; if more than one axis has it, you must qualify it. + +ACTION="${1:-list}" +shift || true + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/driver-registry.sh" + +# Emit "\t\t\t" for every dir-based driver across all +# search bases (a driver is a directory ///). +_plugin_enumerate() { + local kind base axisdir axis namedir name + while IFS=$'\t' read -r kind base; do + [ -d "$base" ] || continue + for axisdir in "$base"/*/; do + [ -d "$axisdir" ] || continue + axis="$(basename "$axisdir")" + for namedir in "$axisdir"*/; do + [ -d "$namedir" ] || continue + name="$(basename "$namedir")" + printf '%s\t%s\t%s\t%s\n' "$kind" "$axis" "$name" "${namedir%/}" + done + done + done < to a single EXTERNAL driver, echoed as "\t\t". +# Bare names match across axes; ambiguity is an error that lists the candidates. +_plugin_resolve_external() { + local ref="$1" want_axis="" want_name matches count + case "$ref" in + */*) want_axis="${ref%%/*}"; want_name="${ref#*/}" ;; + *) want_name="$ref" ;; + esac + matches="$(_plugin_enumerate | awk -F'\t' -v a="$want_axis" -v n="$want_name" \ + '$1=="external" && $3==n && (a=="" || $2==a) { print $2"\t"$3"\t"$4 }')" + count="$(printf '%s' "$matches" | grep -c . || true)" + if [ "$count" -eq 0 ]; then + echo "agmsg plugin: no external driver matches '$ref'" >&2 + return 1 + fi + if [ "$count" -gt 1 ]; then + echo "agmsg plugin: '$ref' is ambiguous across axes:" >&2 + printf '%s\n' "$matches" | awk -F'\t' '{print " "$1"/"$2}' >&2 + echo " qualify it, e.g. agmsg plugin $ACTION $(printf '%s' "$matches" | head -1 | awk -F'\t' '{print $1"/"$2}')" >&2 + return 1 + fi + printf '%s\n' "$matches" +} + +case "$ACTION" in + list) + printf '%-26s %-11s %s\n' "AXIS/NAME" "STATE" "PATH" + _plugin_enumerate | while IFS=$'\t' read -r kind axis name dir; do + if [ "$kind" = builtin ]; then + state="builtin" + elif agmsg_driver_is_trusted "$axis" "$name" "$dir"; then + state="trusted" + else + state="UNTRUSTED" + fi + printf '%-26s %-11s %s\n' "$axis/$name" "$state" "$dir" + done + ;; + trust) + ref="${1:?Usage: plugin.sh trust }" + res="$(_plugin_resolve_external "$ref")" || exit 1 + IFS=$'\t' read -r axis name dir <<<"$res" + agmsg_driver_trust "$axis" "$name" "$dir" + echo "Trusted $axis/$name -> $dir" + ;; + untrust) + ref="${1:?Usage: plugin.sh untrust }" + case "$ref" in + */*) axis="${ref%%/*}"; name="${ref#*/}" ;; + *) res="$(_plugin_resolve_external "$ref")" || exit 1 + IFS=$'\t' read -r axis name _ <<<"$res" ;; + esac + agmsg_driver_untrust "$axis" "$name" + echo "Untrusted $axis/$name" + ;; + *) + echo "Usage: agmsg plugin list|trust |untrust " >&2 + exit 1 + ;; +esac diff --git a/scripts/send.sh b/scripts/send.sh index 9ea5448..c21dd8d 100755 --- a/scripts/send.sh +++ b/scripts/send.sh @@ -12,7 +12,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/lib/storage.sh" DB="$(agmsg_db_path)" -[ -f "$DB" ] || bash "$SCRIPT_DIR/init-db.sh" >/dev/null +[ -f "$DB" ] || bash "$SCRIPT_DIR/internal/init-db.sh" >/dev/null INSERT="INSERT INTO messages (team, from_agent, to_agent, body) VALUES ('$TEAM', '$FROM', '$TO', '$(echo "$BODY" | sed "s/'/''/g")');" @@ -23,7 +23,7 @@ INSERT="INSERT INTO messages (team, from_agent, to_agent, body) VALUES ('$TEAM', # busy_timeout, so re-running it waits for the schema, then the INSERT lands. # See #114. if ! agmsg_sqlite "$DB" "$INSERT" 2>/dev/null; then - bash "$SCRIPT_DIR/init-db.sh" >/dev/null + bash "$SCRIPT_DIR/internal/init-db.sh" >/dev/null agmsg_sqlite "$DB" "$INSERT" fi diff --git a/scripts/session-start.sh b/scripts/session-start.sh index fe9043d..9ae3022 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -40,131 +40,28 @@ source "$SCRIPT_DIR/lib/node.sh" source "$SCRIPT_DIR/lib/hash.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/storage.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" # Identity sanity check — no point launching a watcher with an empty pair set. PAIRS=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null || true) [ -n "$PAIRS" ] || exit 0 -# Resolve the current Codex thread id. CODEX_THREAD_ID is only exported on the -# interactive --remote path; fresh and `codex exec` sessions never export it, so -# fall back to the newest rollout file whose session_meta cwd matches the -# project. Codex writes that rollout ~1s before SessionStart, so it is already -# present; a short bounded retry covers the race if it is not. See #41. -agmsg_resolve_codex_thread() { - local project="$1" - if [ -n "${CODEX_THREAD_ID:-}" ]; then - printf '%s' "$CODEX_THREAD_ID" - return 0 - fi - local sessions_dir="$HOME/.codex/sessions" - [ -d "$sessions_dir" ] || return 0 - # Compare PHYSICAL paths. agmsg may open the project via a symlinked/logical - # path (e.g. a workspace under a symlinked home) while Codex records the - # canonical cwd in session_meta. A raw string compare then misses every - # rollout, so the thread is never resolved and the bridge never starts. See - # #160. Canonicalize the project once; canonicalize each rollout cwd per row. - local project_phys - project_phys=$(agmsg_canonical_path "$project") - local waited=0 f first esc cwd cwd_phys tid - while :; do - while IFS= read -r f; do - [ -f "$f" ] || continue - first=$(head -1 "$f" 2>/dev/null) - case "$first" in *'"session_meta"'*) ;; *) continue ;; esac - esc=$(printf '%s' "$first" | sed "s/'/''/g") - cwd=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.cwd'),'')" 2>/dev/null) - cwd_phys=$(agmsg_canonical_path "$cwd") - [ "$cwd_phys" = "$project_phys" ] || continue - tid=$(agmsg_sqlite_mem "SELECT COALESCE(json_extract('$esc','\$.payload.id'),'')" 2>/dev/null) - if [ -n "$tid" ]; then - printf '%s' "$tid" - return 0 - fi - done </dev/null | head -20) -INNER_EOF - [ "$waited" -ge 2 ] && break - waited=$((waited + 1)) - sleep 1 - done - return 0 -} - -# Codex has no Monitor tool. When launched through codex-monitor.sh, the TUI is -# attached to a shared app-server. Hand the bridge off so incoming agmsg rows -# become turns in the current Codex thread without exposing socket/thread -# plumbing to the user. With AGMSG_CODEX_BRIDGE_LAUNCHER=1 (set by -# codex-monitor.sh) we only write a request file and let the out-of-sandbox -# launcher start the bridge — a hook-launched bridge cannot connect to the unix -# socket from inside the Codex sandbox (#41). -if [ "$TYPE" = "codex" ]; then - thread_id="$(agmsg_resolve_codex_thread "$PROJECT")" - [ -n "$thread_id" ] || exit 0 - app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" - if [ -z "$app_server" ]; then - agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true) - if [ -n "$agent_pid" ]; then - agent_cmd=$(ps -o args= -p "$agent_pid" 2>/dev/null || true) - app_server=$(printf '%s\n' "$agent_cmd" \ - | sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \ - | head -1) - fi - fi - if [ -z "$app_server" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - socket_path="$RUN_DIR/codex-app-server.$project_hash.sock" - if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then - app_server="unix://$socket_path" - fi - fi - [ -n "$app_server" ] || exit 0 - - pair_count=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { c++ } END { print c + 0 }') - [ "$pair_count" = "1" ] || exit 0 - team=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $1; exit }') - name=$(printf '%s\n' "$PAIRS" | awk 'NF >= 2 { print $2; exit }') - [ -n "$team" ] && [ -n "$name" ] || exit 0 - - if [ "${AGMSG_CODEX_BRIDGE_LAUNCHER:-}" = "1" ]; then - project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1) - request_file="$RUN_DIR/codex-bridge-request.$project_hash" - tmp_request="$request_file.$$" - mkdir -p "$RUN_DIR" 2>/dev/null || true - printf '%s\t%s\t%s\t%s\t%s\n' \ - "$TYPE" "$team" "$name" "$thread_id" "$app_server" > "$tmp_request" - mv "$tmp_request" "$request_file" - exit 0 - fi - - mkdir -p "$RUN_DIR" 2>/dev/null || true - pidfile="$RUN_DIR/codex-bridge.$team.$name.pid" - if [ -f "$pidfile" ]; then - bridge_pid=$(cat "$pidfile" 2>/dev/null || true) - if [ -n "$bridge_pid" ] && kill -0 "$bridge_pid" 2>/dev/null; then - exit 0 - fi - fi - - log="$RUN_DIR/codex-bridge.$team.$name.log" - # An explicit AGMSG_CODEX_BRIDGE_CMD is a complete runnable (tests, custom - # wrappers) — run it as-is. Only the default codex-bridge.js is launched - # through a resolved Node, since its env-node shebang fails in shells where a - # version-manager Node is not on PATH (#170). - if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then - bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") - else - bridge_run=("$(agmsg_resolve_node)" "$SCRIPT_DIR/codex-bridge.js") - fi - nohup "${bridge_run[@]}" \ - --project "$PROJECT" \ - --type "$TYPE" \ - --team "$team" \ - --name "$name" \ - --thread "$thread_id" \ - --app-server "$app_server" \ - --inline-inbox \ - >>"$log" 2>&1 & - exit 0 +# Type-specific SessionStart behaviour (Template Method). A type may ship +# scripts/drivers/types//_session-start.sh defining agmsg_session_start to override the +# default no-op — codex uses it to hand the session off to the bridge. The plug +# is sourced in this script's context so it sees PROJECT / RUN_DIR / SKILL_DIR / +# PAIRS and the helpers sourced above; it may exit 0 (codex does, having no +# Monitor tool) to skip the Monitor-directive path below. +agmsg_session_start_default() { :; } + +_tdir="$(agmsg_type_dir "$TYPE" 2>/dev/null || true)" +if [ -n "$_tdir" ] && [ -f "$_tdir/_session-start.sh" ]; then + # shellcheck disable=SC1090 + . "$_tdir/_session-start.sh" + agmsg_session_start +else + agmsg_session_start_default fi # Read hook input JSON from stdin. session_id field is sent for SessionStart. diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 7f09294..bfe2950 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -15,7 +15,8 @@ set -euo pipefail # Usage: # spawn.sh [options] # -# claude-code | codex (only these two are supported today) +# any registered type whose manifest is spawnable: a `cli=` +# binary (direct-CLI launch) or a `spawn=` node launcher # actas identity for the spawned agent # # Options: @@ -42,7 +43,8 @@ set -euo pipefail # right after spawn returns without racing the agent's cold start. Codex has no # Monitor, so the wait is skipped for codex. # -# Scope note: claude-code/codex only; macOS is the primary target, Linux and +# Scope note: spawnable types are those whose manifest declares `spawnable=yes`; +# macOS is the primary target, Linux and # Windows are best-effort (no guarantee — please open an issue/PR if a given # terminal does not work). Headless environments (no tmux and no usable # terminal) error out, because the agent CLIs need an interactive terminal. @@ -53,6 +55,8 @@ TEAMS_DIR="$SKILL_DIR/teams" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/actas-lock.sh" # shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" +# shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/storage.sh" die() { echo "spawn: $*" >&2; exit 1; } @@ -64,13 +68,33 @@ NAME="${2:-}" [ -n "$NAME" ] || die "Usage: spawn.sh [options]" shift 2 || true -case "$AGENT_TYPE" in - claude-code|codex) ;; - gemini|antigravity|copilot|opencode) - die "agent type '$AGENT_TYPE' is not supported by spawn yet (supported: claude-code, codex)" ;; - *) - die "unknown agent type '$AGENT_TYPE' (supported: claude-code, codex)" ;; -esac +# A type is spawnable iff its manifest declares `spawnable=yes` (direct-CLI) OR a +# `spawn=` node launcher. The error lists the computed spawnable set from the +# registry — no type name is hardcoded here. +spawnable_types() { + local t + while IFS= read -r t; do + [ -n "$t" ] || continue + # `if` (not `&& printf`) so a non-spawnable last type does not leave the loop + # — and thus the function — with a non-zero status, which `set -e`+pipefail + # would turn into a silent exit at the `SUPPORTED_LIST=$(...)` assignment. + if [ "$(agmsg_type_get "$t" spawnable)" = "yes" ] || [ -n "$(agmsg_type_get "$t" spawn)" ]; then + printf '%s\n' "$t" + fi + done </dev/null 2>&1 \ - || die "'$CLI_BIN' not found on PATH — install the ${AGENT_TYPE} CLI first" +# --- Resolve the launch method from the manifest --- +# A non-empty `spawn=` launcher means this type runs via a Node launcher (e.g. an +# external add-on); otherwise it is a direct-CLI launch. The `cli=` binary is +# REQUIRED for direct-CLI types and OPTIONAL for node launchers (which resolve +# their own runtime). No per-type case — all data-driven from the manifest. +SPAWN_LAUNCHER="$(agmsg_type_get "$AGENT_TYPE" spawn)" +CLI_BIN="$(agmsg_type_get "$AGENT_TYPE" cli)" +CLI_PATH="" +if [ -n "$CLI_BIN" ]; then + command -v "$CLI_BIN" >/dev/null 2>&1 \ + || die "'$CLI_BIN' not found on PATH — install the ${AGENT_TYPE} CLI first" + CLI_PATH="$(command -v "$CLI_BIN")" +elif [ -z "$SPAWN_LAUNCHER" ]; then + die "agent type '$AGENT_TYPE' manifest declares neither a 'cli' binary nor a 'spawn' launcher" +fi +# Resolve the node launcher path from the manifest (not hardcoded), if any. +SPAWN_AGENT="" +if [ -n "$SPAWN_LAUNCHER" ]; then + NODE_BIN="${AGMSG_NODE_BIN:-$(command -v node 2>/dev/null || true)}" + [ -n "$NODE_BIN" ] || die "'node' not found on PATH — spawning '$AGENT_TYPE' requires Node.js" + type_dir="$(agmsg_type_dir "$AGENT_TYPE")" \ + || die "agent type '$AGENT_TYPE' is not registered (no scripts/drivers/types/$AGENT_TYPE/type.conf)" + SPAWN_AGENT="$type_dir/$SPAWN_LAUNCHER" + [ -f "$SPAWN_AGENT" ] || die "spawn launcher not found for '$AGENT_TYPE': $SPAWN_AGENT" +fi # --- Resolve the team to join into --- # When --team is omitted, derive it from any team that already has an agent @@ -226,7 +268,18 @@ BOOT="$BOOT.command" { echo '#!/usr/bin/env bash' printf 'cd %q || exit 1\n' "$PROJECT" - printf '%q %q\n' "$CLI_BIN" "$ACTAS_PROMPT" + if [ -n "$SPAWN_AGENT" ]; then + # Node-launcher path: pass the universal agmsg context + the actas prompt. + # Type-specific config is the launcher's own default/env, so core stays + # generic and names no add-on. + printf '%q %q \\\n' "$NODE_BIN" "$SPAWN_AGENT" + printf ' --name %q \\\n' "$NAME" + printf ' --team %q \\\n' "$TEAM" + printf ' --project %q \\\n' "$PROJECT" + printf ' --initial-input %q\n' "$ACTAS_PROMPT" + else + printf '%q %q\n' "$CLI_BIN" "$ACTAS_PROMPT" + fi echo 'rm -f "$0" 2>/dev/null' # self-clean once the agent exits echo 'exec "${SHELL:-/bin/bash}" -i' } > "$BOOT" @@ -370,12 +423,12 @@ place_and_launch() { # receiving. Block until that appears so the leader doesn't send a job into the # cold-start window (before the watcher attaches) and lose it. # -# Codex has no Monitor/watcher, so nothing would ever touch the sentinel — -# skip the wait for codex (its receive is poll-based anyway). +# Types without a Monitor (manifest `monitor=no`) never touch the readiness +# sentinel, so skip the wait for them (their receive is poll-based anyway). READY_PATH="$(agmsg_ready_path "$TEAM" "$NAME")" -if [ "$AGENT_TYPE" = "codex" ] && [ "$WAIT_READY" = "1" ]; then +if [ "$(agmsg_type_get "$AGENT_TYPE" monitor)" = "no" ] && [ "$WAIT_READY" = "1" ]; then WAIT_READY=0 - echo "spawn: codex has no Monitor — skipping readiness wait (--no-wait implied)" >&2 + echo "spawn: '$AGENT_TYPE' has no Monitor — skipping readiness wait (--no-wait implied)" >&2 fi # Clear any stale sentinel before launching so we only observe THIS spawn's diff --git a/scripts/whoami.sh b/scripts/whoami.sh index c5fcfe8..a22d4ed 100755 --- a/scripts/whoami.sh +++ b/scripts/whoami.sh @@ -11,57 +11,66 @@ set -euo pipefail # type: claude-code, codex, gemini, antigravity, copilot, opencode # If type is omitted, auto-detect from env vars and process tree. -# Auto-detect CLI type from environment variables and process tree -detect_cli_type() { - # 1. Check environment variables. Order matters: prefer the env vars that - # the runtime *itself* exports for its own session over the env vars users - # commonly set globally for unrelated reasons. CLAUDE_CODE_SESSION_ID and - # CODEX_SANDBOX / CODEX_THREAD_ID are set by their runtimes only. The - # GEMINI_API_KEY family is also routinely set by users of the Gemini API - # SDK without the Gemini CLI being involved, so it goes last. - if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then - echo "claude-code" - return 0 - fi - - if [ -n "${CODEX_SANDBOX:-}" ] || [ -n "${CODEX_THREAD_ID:-}" ]; then - echo "codex" - return 0 - fi - - if [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GOOGLE_GEMINI_CLI:-}" ]; then - echo "gemini" - return 0 - fi - - # 2. Fall back to process tree detection - local pid=$$ - local max_depth=10 - local depth=0 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" +# Auto-detect CLI type from environment variables and the process tree, driven by +# the per-type manifests' `detect=` (env-var names) and `detect_proc=` (process +# name globs) keys — no hardcoded type list lives here. +detect_cli_type() { + # `detect=` / `detect_proc=` tokens are split with `read -ra` (IFS word-split, + # NO pathname expansion) rather than an unquoted `for x in $list` — a file in + # the caller's cwd matching a pattern like `claude-*` must not glob-eat the + # pattern. (Plain `set -f` can't be used here: agmsg_known_types discovers types + # via a `*/` glob that must keep working.) + + # 1. Environment variables. Sorted registry order preserves the historical + # precedence: a runtime's own session vars (CLAUDE_CODE_SESSION_ID, CODEX_*) are + # checked before the GEMINI_* family, which users also set for the SDK without + # the CLI. `detect=explicit` (and types with no detect=) are never auto-detected. + local _t _v _detect _toks + while IFS= read -r _t; do + [ -n "$_t" ] || continue + _detect="$(agmsg_type_get "$_t" detect)" + if [ -z "$_detect" ] || [ "$_detect" = "explicit" ]; then + continue + fi + read -ra _toks <<<"$_detect" + for _v in "${_toks[@]}"; do + if [ -n "${!_v:-}" ]; then + echo "$_t" + return 0 + fi + done + done </dev/null | xargs basename 2>/dev/null || true) - - case "$proc_name" in - codex|codex-*) - echo "codex" - return 0 - ;; - gemini|gemini-*) - echo "gemini" - return 0 - ;; - claude|claude-code|claude-*) - echo "claude-code" - return 0 - ;; - opencode|opencode-*) - echo "opencode" - return 0 - ;; - esac + if [ -n "$proc_name" ]; then + while IFS= read -r _t; do + [ -n "$_t" ] || continue + _pats="$(agmsg_type_get "$_t" detect_proc)" + [ -n "$_pats" ] || continue + read -ra _toks <<<"$_pats" + for _pat in "${_toks[@]}"; do + # $_pat is intentionally an UNQUOTED glob pattern matched against the + # process name; read -ra already kept it out of pathname expansion. + # shellcheck disable=SC2254 + case "$proc_name" in + $_pat) echo "$_t"; return 0 ;; + esac + done + done </dev/null | tr -d ' ' || true) @@ -75,7 +84,7 @@ detect_cli_type() { PROJECT_PATH="${1:?Usage: whoami.sh [type]}" AGENT_TYPE="${2:-$(detect_cli_type)}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# SCRIPT_DIR is already resolved above (before sourcing the type registry). SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TEAMS_DIR="$SCRIPT_DIR/../teams" diff --git a/scripts/windows/agmsg.ps1 b/scripts/windows/agmsg.ps1 index 30070e4..883aea9 100644 --- a/scripts/windows/agmsg.ps1 +++ b/scripts/windows/agmsg.ps1 @@ -98,7 +98,7 @@ function Test-SqliteAvailable { $script:Bash = Find-GitBash Test-SqliteAvailable -$dispatcher = Join-Path $script:ScriptsDir 'dispatch.sh' +$dispatcher = Join-Path $PSScriptRoot 'dispatch.sh' if (-not (Test-Path -LiteralPath $dispatcher)) { throw "Missing agmsg dispatcher: $dispatcher" } diff --git a/scripts/dispatch.sh b/scripts/windows/dispatch.sh similarity index 89% rename from scripts/dispatch.sh rename to scripts/windows/dispatch.sh index d96d9d5..c61665c 100755 --- a/scripts/dispatch.sh +++ b/scripts/windows/dispatch.sh @@ -69,7 +69,7 @@ fi run_script() { local script="$1" shift - bash "$SCRIPT_DIR/$script" "$@" + bash "$SCRIPT_DIR/../$script" "$@" } require_args() { @@ -205,14 +205,28 @@ case "$COMMAND" in fi ;; + plugin) + if [ "$#" -eq 0 ]; then + run_script plugin.sh list + else + run_script plugin.sh "$@" + fi + ;; + mode) case "$#" in 0) run_script delivery.sh status "$AGENT_TYPE" "$PROJECT" ;; 1) + # Platform-specific guard (not incidental type coupling): the Codex + # monitor bridge is unsupported on Windows (its tests skip_on_windows, + # #182), so codex monitor/both can't work here even though the codex + # manifest lists 'monitor' for POSIX. delivery_modes is a global fact and + # can't express "monitor everywhere except Windows", so this one Windows + # dispatcher keeps an explicit early reject with a clear message. if [ "$AGENT_TYPE" = "codex" ] && { [ "$1" = "monitor" ] || [ "$1" = "both" ]; }; then - echo "Codex has no Monitor tool; only 'turn' or 'off' modes are supported." >&2 + echo "Codex has no Monitor tool on Windows; only 'turn' or 'off' modes are supported." >&2 exit 2 fi run_script delivery.sh set "$1" "$AGENT_TYPE" "$PROJECT" diff --git a/tests/smoke_windows_powershell.ps1 b/tests/smoke_windows_powershell.ps1 index 8d19abe..7ebc26e 100644 --- a/tests/smoke_windows_powershell.ps1 +++ b/tests/smoke_windows_powershell.ps1 @@ -52,6 +52,10 @@ $projectMulti = Join-Path $testRoot 'project-multi' try { New-Item -ItemType Directory -Force -Path $scriptsDir, (Join-Path $skillDir 'db'), (Join-Path $skillDir 'teams'), $storageDir, $projectSingle, $projectBob, $projectMulti | Out-Null + # Recursive copy brings scripts/ including the folded scripts/drivers/types/ + # manifests + per-type runtimes, so join.sh's type-registry validation resolves + # scripts/drivers/types//type.conf relative to the skill dir (the real + # installer ships them the same way via the single scripts/ copy in install.sh). Copy-Item -Recurse -Force -Path (Join-Path $RepoRoot 'scripts/*') -Destination $scriptsDir $wrapper = Join-Path $scriptsDir 'windows/agmsg.ps1' @@ -73,7 +77,7 @@ try { $projectBobBash = (& $bash -lc 'cygpath -u "$1"' agmsg-path $projectBob | Out-String).Trim() $projectMultiBash = (& $bash -lc 'cygpath -u "$1"' agmsg-path $projectMulti | Out-String).Trim() - & $bash (Join-Path $scriptsDir 'init-db.sh') | Out-Null + & $bash (Join-Path (Join-Path $scriptsDir 'internal') 'init-db.sh') | Out-Null if ($LASTEXITCODE -ne 0) { throw "init-db failed: $LASTEXITCODE" } & $bash (Join-Path $scriptsDir 'join.sh') demo alice codex $projectSingleBash | Out-Null if ($LASTEXITCODE -ne 0) { throw "join alice failed: $LASTEXITCODE" } diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index d401025..fb9226c 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -16,28 +16,28 @@ teardown() { } @test "codex-bridge: help exits successfully" { - run node "$SCRIPTS/codex-bridge.js" --help + run node "$TYPES/codex/codex-bridge.js" --help [ "$status" -eq 0 ] [[ "$output" =~ "Beta Codex app-server bridge" ]] } @test "codex-bridge: resolve-only prints the selected identity" { skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only [ "$status" -eq 0 ] [ "$output" = $'team\talice' ] } @test "codex-bridge: resolve-only rejects ambiguous identities" { skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --resolve-only + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --resolve-only [ "$status" -eq 1 ] [[ "$output" =~ "multiple identities match" ]] } @test "codex-bridge: rejects unsupported app-server endpoints" { skip_on_windows "codex bridge identity resolution on Windows (#182)" - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 [ "$status" -eq 1 ] [[ "$output" =~ "supports only unix://PATH or ws://host:port" ]] } @@ -181,7 +181,7 @@ EOF sleep 0.1 done - run node "$SCRIPTS/codex-bridge.js" \ + run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "unix://$sock" --timeout 1 --interval 1 --max-wakes 1 @@ -316,7 +316,7 @@ EOF local port port="$(cat "$portfile")" - run node "$SCRIPTS/codex-bridge.js" \ + run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "ws://127.0.0.1:$port" --timeout 1 --interval 1 --max-wakes 1 @@ -333,7 +333,7 @@ EOF mkdir -p "$TEST_SKILL_DIR/run" echo "$$" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice [ "$status" -eq 1 ] [[ "$output" =~ "bridge already running" ]] } @@ -396,7 +396,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 [ "$status" -eq 0 ] @@ -441,7 +441,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing --timeout 20 [ "$status" -eq 0 ] @@ -489,7 +489,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 5000 --timeout 20 [ "$status" -eq 0 ] @@ -523,7 +523,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 1500 --timeout 20 [ "$status" -ne 0 ] @@ -590,7 +590,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 --inline-inbox [ "$status" -eq 0 ] @@ -653,7 +653,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 [ "$status" -eq 1 ] @@ -700,7 +700,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 1 --max-wakes 2 [ "$status" -eq 0 ] @@ -746,7 +746,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] @@ -806,7 +806,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-active --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] # not exit 1 from the stale-wake guard diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index 090f3ef..ec291c6 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -38,7 +38,7 @@ teardown() { @test "codex shim: monitor project routes resume through codex-monitor" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex-shim.sh" resume --last' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" resume --last' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--last>" "$CALL_LOG" @@ -47,7 +47,7 @@ teardown() { @test "codex shim: monitor project routes prompt launches through top-level codex" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex-shim.sh" "fix this"' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" "fix this"' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> " "$CALL_LOG" @@ -57,7 +57,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" resume --last + run bash "$TYPES/codex/codex-shim.sh" resume --last [ "$status" -eq 0 ] grep -q "real-codex <--last>" "$CALL_LOG" @@ -68,7 +68,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" exec echo hi + run bash "$TYPES/codex/codex-shim.sh" exec echo hi [ "$status" -eq 0 ] grep -q "real-codex " "$CALL_LOG" @@ -79,7 +79,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" --cd "$TEST_PROJECT" resume + run bash "$TYPES/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--cd> <$TEST_PROJECT>" "$CALL_LOG" diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 1a5b5a3..c6b98af 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -172,21 +172,6 @@ settings_file() { [[ "$output" =~ "mode: off" ]] } -# --- hook.sh backward compat --- - -@test "hook.sh on delegates to delivery set turn" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" 2>&1 - has_check_inbox "$(settings_file)" - ! has_session_start "$(settings_file)" -} - -@test "hook.sh off delegates to delivery set off" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" 2>&1 - bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" 2>&1 - ! has_check_inbox "$(settings_file)" - ! has_session_start "$(settings_file)" -} - # --- rejects unknown mode --- @test "delivery set: rejects unknown mode" { @@ -467,22 +452,6 @@ JSON kill "$alive_pid" 2>/dev/null || true } -# --- hook.sh deprecation notice --- - -@test "hook.sh on prints a deprecation notice on stderr" { - run bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - # Combined stderr+stdout is captured by `run` — assert the notice appears. - [[ "$output" =~ "deprecated" ]] -} - -@test "hook.sh off prints a deprecation notice on stderr" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" >/dev/null - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "deprecated" ]] -} - # --- emit_monitor_directive idempotency --- @test "emit monitor directive: skips when a live watcher already exists for this session" { @@ -1273,10 +1242,12 @@ EOF grep -q "session-start.sh" "$hook_file" } -@test "delivery set both (codex): rejected for bridge beta" { +@test "delivery set both (codex): rejected by the delivery_modes gate" { + # codex's manifest omits 'both' (delivery_modes=monitor turn off), so the + # central gate in delivery.sh rejects it before any file is touched. run bash "$SCRIPTS/delivery.sh" set both codex "$TEST_PROJECT" [ "$status" -eq 1 ] - [[ "$output" == *"not supported for codex bridge beta"* ]] + [[ "$output" == *"not supported for codex"* ]] } @@ -1338,3 +1309,56 @@ EOF [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] kill "$bpid" 2>/dev/null || true } + +# --- hermes (manual-only: delivery_modes=off, no automatic hook) --- + +@test "delivery hermes: status is manual/off" { + run bash "$SCRIPTS/delivery.sh" status hermes "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" =~ "mode: off" ]] +} + +@test "delivery hermes: rejects automatic modes" { + local mode + for mode in turn monitor both; do + run bash "$SCRIPTS/delivery.sh" set "$mode" hermes "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "not supported for hermes" ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] + done +} + +@test "delivery hermes: rejects unknown mode" { + run bash "$SCRIPTS/delivery.sh" set bogus hermes "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown mode" ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] +} + +@test "delivery hermes: accepts off without writing hook config" { + run bash "$SCRIPTS/delivery.sh" set off hermes "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" =~ "Delivery mode set to 'off'" ]] + [[ "$output" =~ "manual inbox checks only" ]] + [[ "$output" != *"AGMSG-DIRECTIVE"* ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] +} + +@test "delivery hermes: set off does not stop Claude Code watchers for the same project" { + mkdir -p "$TEST_SKILL_DIR/teams/myteam" + cat > "$TEST_SKILL_DIR/teams/myteam/config.json" </dev/null || true + wait 2>/dev/null || true +} diff --git a/tests/test_dispatch.bats b/tests/test_dispatch.bats index 889de26..3fdcd22 100644 --- a/tests/test_dispatch.bats +++ b/tests/test_dispatch.bats @@ -17,19 +17,19 @@ teardown() { } @test "dispatch: explicit team and agent can check inbox" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_BOB" --team demo --agent bob -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_BOB" --team demo --agent bob -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @test "dispatch: environment team and agent can check inbox" { - run env AGMSG_TEAM=demo AGMSG_AGENT=bob bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_BOB" -- inbox + run env AGMSG_TEAM=demo AGMSG_AGENT=bob bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_BOB" -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @test "dispatch: whoami single identity resolves inbox" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @@ -38,7 +38,7 @@ teardown() { bash "$SCRIPTS/join.sh" many first codex "$PROJECT_MULTI" bash "$SCRIPTS/join.sh" many second codex "$PROJECT_MULTI" - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_MULTI" -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_MULTI" -- inbox [ "$status" -eq 2 ] [[ "$output" =~ "multiple=true" ]] [[ "$output" =~ "agmsg -Team -Agent inbox" ]] @@ -46,20 +46,20 @@ teardown() { @test "dispatch: send then history preserves Japanese, quotes, and emoji" { local message='確認しました "quoted" emoji 🚀' - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo --agent alice -- send bob "$message" + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo --agent alice -- send bob "$message" [ "$status" -eq 0 ] - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo -- history + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo -- history [ "$status" -eq 0 ] [[ "$output" =~ "$message" ]] } @test "dispatch: codex mode off and turn delegate to delivery" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode off + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode off [ "$status" -eq 0 ] [[ "$output" =~ "Delivery mode set to 'off'" ]] - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode turn + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode turn [ "$status" -eq 0 ] [[ "$output" =~ "Delivery mode set to 'turn'" ]] } diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 831a827..2c88e76 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -11,11 +11,18 @@ setup_test_env() { chmod +x "$TEST_SKILL_DIR/scripts/"*.sh chmod +x "$TEST_SKILL_DIR/scripts/"*.js 2>/dev/null || true + # Agent-type manifests + per-type runtimes now live under scripts/drivers/types/ + # (the type registry reads /scripts/drivers/types//type.conf), + # so the recursive scripts/ copy above already brings them along — no separate + # copy is needed. Just ensure codex's folded runtime scripts stay executable. + chmod +x "$TEST_SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true + # Initialize DB - bash "$TEST_SKILL_DIR/scripts/init-db.sh" + bash "$TEST_SKILL_DIR/scripts/internal/init-db.sh" # Convenience vars export SCRIPTS="$TEST_SKILL_DIR/scripts" + export TYPES="$TEST_SKILL_DIR/scripts/drivers/types" # Sandbox HOME so NO test can touch the developer's real home. Several paths # write under $HOME — e.g. codex-shim-install.sh creates $HOME/.agents/bin/codex diff --git a/tests/test_hook.bats b/tests/test_hook.bats deleted file mode 100644 index 1276c2e..0000000 --- a/tests/test_hook.bats +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { - setup_test_env - # Pin bare instance-id keying (#93) so check-inbox's watcher-defer and actas - # owner checks key on the raw session_id these tests pass — deterministic in - # CI and when the suite runs under an agent process. - export AGMSG_AGENT_PID="" - export TEST_PROJECT="$(mktemp -d)" -} - -teardown() { - rm -rf "$TEST_PROJECT" - teardown_test_env -} - -# --- hook.sh on --- - -@test "hook on: creates settings.local.json" { - run bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [ -f "$TEST_PROJECT/.claude/settings.local.json" ] - [[ "$output" =~ "Delivery mode set to 'turn'" ]] -} - -@test "hook on: settings contains Stop hook with check-inbox" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "Stop" ]] - [[ "$content" =~ "check-inbox.sh" ]] -} - -@test "hook on: is idempotent" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local count=$(python3 -c " -import json -d = json.load(open('$TEST_PROJECT/.claude/settings.local.json')) -print(len(d['hooks']['Stop'])) -") - [ "$count" -eq 1 ] -} - -@test "hook on: preserves existing settings" { - mkdir -p "$TEST_PROJECT/.claude" - echo '{"permissions":{"allow":["Bash"]}}' > "$TEST_PROJECT/.claude/settings.local.json" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "permissions" ]] - [[ "$content" =~ "Stop" ]] -} - -@test "hook on: handles path with spaces" { - local spaced="$(mktemp -d)/my project" - mkdir -p "$spaced" - run bash "$SCRIPTS/hook.sh" on claude-code "$spaced" - [ "$status" -eq 0 ] - [ -f "$spaced/.claude/settings.local.json" ] - rm -rf "$spaced" -} - -@test "hook on: codex creates hooks.json" { - run bash "$SCRIPTS/hook.sh" on codex "$TEST_PROJECT" - [ "$status" -eq 0 ] - [ -f "$TEST_PROJECT/.codex/hooks.json" ] - [[ "$output" =~ "Delivery mode set to 'turn'" ]] -} - -@test "hook off: codex removes hook" { - bash "$SCRIPTS/hook.sh" on codex "$TEST_PROJECT" - run bash "$SCRIPTS/hook.sh" off codex "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] -} - -# --- hook.sh off --- - -@test "hook off: removes hook from settings" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ ! "$content" =~ "Stop" ]] -} - -@test "hook off: reports no hook when not configured" { - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] -} - -@test "hook off: preserves other settings" { - mkdir -p "$TEST_PROJECT/.claude" - echo '{"permissions":{"allow":["Bash"]}}' > "$TEST_PROJECT/.claude/settings.local.json" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "permissions" ]] - [[ ! "$content" =~ "Stop" ]] -} - -# --- check-inbox.sh --- - -@test "check-inbox: respects cooldown" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - # First call creates marker - echo '{}' | bash "$SCRIPTS/check-inbox.sh" claude-code "$TEST_PROJECT" - # Send message after marker - bash "$SCRIPTS/send.sh" testteam bob alice "hello" - # Second call within cooldown should skip - run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "check-inbox: exits silently when not joined" { - run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code /tmp/nowhere" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "check-inbox: exits silently when stop_hook_active" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "hello" - run bash -c 'echo "{\"stop_hook_active\":true}" | bash "'"$SCRIPTS"'/check-inbox.sh" claude-code "'"$TEST_PROJECT"'"' - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -# Stop-hook delivery should respect actas exclusivity locks the same way -# the Monitor-mode watcher does (#62). If a peer session owns (team, alice), -# this session must not consume alice's inbox here — that would defeat the -# whole exclusivity guarantee for codex / claude-code-turn delivery paths. -@test "check-inbox: skips a team when (team, agent) is locked by another live session" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "should not be delivered here" - - setup_live_owner "$TEST_SKILL_DIR/run" "peer-sid" - echo "peer-sid" > "$TEST_SKILL_DIR/run/actas.testteam__alice.session" - - run bash -c "echo '{\"session_id\":\"mine-sid\"}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - # The message should NOT surface — no "block decision" payload, no body. - [[ ! "$output" =~ "should not be delivered here" ]] -} - -@test "check-inbox: still delivers when the lock is owned by this session" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "I am the owner" - - setup_live_owner "$TEST_SKILL_DIR/run" "mine-sid" - echo "mine-sid" > "$TEST_SKILL_DIR/run/actas.testteam__alice.session" - - run bash -c "echo '{\"session_id\":\"mine-sid\"}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - [[ "$output" =~ "I am the owner" ]] -} diff --git a/tests/test_install.bats b/tests/test_install.bats index df84921..893cef6 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -199,7 +199,7 @@ wait_for_pidfile_pid() { [ ! -f "$FAKE_HOME/.agents/bin/sqlite3" ] [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/agmsg.ps1" ] [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/install-agmsg.ps1" ] - [ -f "$FAKE_HOME/.agents/skills/msg/scripts/dispatch.sh" ] + [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/dispatch.sh" ] } @test "install --update: removes legacy Windows runner and sqlite shim" { @@ -234,7 +234,7 @@ PS1 [ -f "$SK/scripts/windows/agmsg.ps1" ] [ -f "$SK/scripts/windows/install-agmsg.ps1" ] - [ -f "$SK/scripts/dispatch.sh" ] + [ -f "$SK/scripts/windows/dispatch.sh" ] [ ! -f "$SK/scripts/windows/agmsg-run.sh" ] [ ! -f "$SK/scripts/windows/sqlite3-shim.sh" ] } @@ -457,3 +457,84 @@ PY fi } + +# --- hermes Agent skill (~/.hermes/skills//SKILL.md) --- + +@test "install: drops a Hermes skill when ~/.hermes exists" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + local hermes_skill="$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" + [ -f "$hermes_skill" ] + grep -q "whoami.sh \"\$(pwd)\" hermes" "$hermes_skill" + grep -q "^name: agmsg" "$hermes_skill" + grep -q "~/.agents/skills/agmsg/scripts" "$hermes_skill" +} + +@test "install: custom command name is substituted in Hermes skill" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd msg + local hermes_skill="$FAKE_HOME/.hermes/skills/msg/SKILL.md" + [ -f "$hermes_skill" ] + grep -q "^name: msg" "$hermes_skill" + grep -q "~/.agents/skills/msg/scripts" "$hermes_skill" + grep -q "You can now use \`/msg\`" "$hermes_skill" + ! grep -q "__SKILL_NAME__" "$hermes_skill" +} + +@test "install: --agent-type hermes makes shared SKILL.md Hermes-typed" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg --agent-type hermes + grep -q "whoami.sh \"\$(pwd)\" hermes" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" codex" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" gemini" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" antigravity" "$SK/SKILL.md" +} + +@test "install --update: refreshes the Hermes skill if it was previously installed" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + local hermes_skill="$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" + [ -f "$hermes_skill" ] + echo "tampered" > "$hermes_skill" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + ! grep -q "^tampered$" "$hermes_skill" + grep -q "whoami.sh \"\$(pwd)\" hermes" "$hermes_skill" +} + +@test "install --update: installs Hermes skill for upgraders without prior skill" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ ! -d "$FAKE_HOME/.hermes/skills/agmsg" ] + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + [ -f "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" ] + grep -q "whoami.sh \"\$(pwd)\" hermes" "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" +} + +@test "install: --update re-points an existing Codex monitor shim to the new path" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + # Install the shim the way enabling Codex monitor mode would. + HOME="$FAKE_HOME" bash "$SK/scripts/drivers/types/codex/codex-shim-install.sh" install >/dev/null + local shim="$FAKE_HOME/.agents/bin/codex" + [ -f "$shim" ] + grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + + # Simulate a shim baked by a pre-1.1.0 layout (stale exec path), keeping the + # agmsg marker so it is still recognized as ours. + local tmp; tmp="$(mktemp)" + sed 's#/scripts/drivers/types/codex/#/scripts/codex/#g' "$shim" > "$tmp" + mv "$tmp" "$shim" + grep -q '/scripts/codex/codex-shim.sh' "$shim" + ! grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + + # --update must regenerate it back to the post-move path. + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + ! grep -q '/scripts/codex/codex-shim.sh' "$shim" +} + +@test "install: --update does NOT create a Codex shim when none was installed" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ ! -e "$FAKE_HOME/.agents/bin/codex" ] + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + # The refresh is gated on an existing agmsg shim — it must not opt the user in. + [ ! -e "$FAKE_HOME/.agents/bin/codex" ] +} diff --git a/tests/test_plugin_registry.bats b/tests/test_plugin_registry.bats new file mode 100644 index 0000000..9c5736f --- /dev/null +++ b/tests/test_plugin_registry.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +# External-plugin discovery + opt-in (trust) gating for the driver registry. +# External drivers run shell code with the user's privileges, so they are NEVER +# loaded unless explicitly trusted (`agmsg plugin trust`). These tests lock that +# contract for the "types" axis: ignored-until-trusted, later-wins override, +# path-pinned trust (no swap), the warning, and the plugin CLI. +# +# for the registry is TEST_SKILL_DIR (driver-registry resolves up two from +# scripts/lib/), so the default external base is $TEST_SKILL_DIR/plugins and the +# trust allowlist is $TEST_SKILL_DIR/db/trusted-plugins. + +load test_helper + +setup() { setup_test_env; } +teardown() { teardown_test_env; } + +# Make a minimal external 'types' driver at /types/ with a distinctive +# hooks_file so overrides are observable. +mk_ext_type() { + local base="$1" name="$2" hooks="${3:-.x/$2.json}" + local d="$base/types/$name" + mkdir -p "$d" + printf 'name=%s\ntemplate=template.md\nhooks_file=%s\n' "$name" "$hooks" > "$d/type.conf" + printf '# external template\n' > "$d/template.md" +} + +known() { + bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" 2>/dev/null +} + +@test "plugin: an external type under /plugins is ignored until trusted" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + run known + ! echo "$output" | grep -qx foo + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir foo" + [ "$status" -ne 0 ] +} + +@test "plugin: an untrusted external type warns with the opt-in command" { + mk_ext_type "$TEST_SKILL_DIR/plugins" bar + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types >/dev/null" + echo "$output" | grep -q "not trusted" + echo "$output" | grep -q "agmsg plugin trust types/bar" +} + +@test "plugin trust: makes an external type discoverable" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + run bash "$SCRIPTS/plugin.sh" trust types/foo + [ "$status" -eq 0 ] + run known + echo "$output" | grep -qx foo + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get foo hooks_file" + [ "$output" = ".x/foo.json" ] +} + +@test "plugin trust: a trusted external overrides a built-in (later-wins)" { + # Built-in codex hooks_file is .codex/hooks.json; the trusted plugin shadows it. + mk_ext_type "$TEST_SKILL_DIR/plugins" codex ".over/codex.json" + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".codex/hooks.json" ] # untrusted: built-in still wins + bash "$SCRIPTS/plugin.sh" trust types/codex + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".over/codex.json" ] # trusted: plugin overrides +} + +@test "plugin: AGMSG_PLUGIN_DIRS supplies external types (when trusted)" { + local ext="$TEST_SKILL_DIR/extra" + mk_ext_type "$ext" baz + run bash -c "export AGMSG_PLUGIN_DIRS='$ext'; source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" + ! echo "$output" | grep -qx baz + AGMSG_PLUGIN_DIRS="$ext" bash "$SCRIPTS/plugin.sh" trust types/baz + run bash -c "export AGMSG_PLUGIN_DIRS='$ext'; source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" + echo "$output" | grep -qx baz +} + +@test "plugin: trust is path-pinned — a name trusted at a different path is not honored" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + # Hand-write a trust entry pointing somewhere else; the real dir must stay ignored. + mkdir -p "$TEST_SKILL_DIR/db" + printf 'types/foo\t/some/other/path\n' > "$TEST_SKILL_DIR/db/trusted-plugins" + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir foo" + [ "$status" -ne 0 ] +} + +@test "plugin untrust: revokes a previously trusted external type" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + bash "$SCRIPTS/plugin.sh" trust types/foo + run known; echo "$output" | grep -qx foo + bash "$SCRIPTS/plugin.sh" untrust types/foo + run known; ! echo "$output" | grep -qx foo +} + +@test "plugin trust: a bare ambiguous name errors and lists axis-qualified candidates" { + mkdir -p "$TEST_SKILL_DIR/plugins/types/dup" "$TEST_SKILL_DIR/plugins/storage/dup" + printf 'name=dup\n' > "$TEST_SKILL_DIR/plugins/types/dup/type.conf" + printf 'name=dup\n' > "$TEST_SKILL_DIR/plugins/storage/dup/driver.conf" + run bash "$SCRIPTS/plugin.sh" trust dup + [ "$status" -ne 0 ] + echo "$output" | grep -q "ambiguous" + echo "$output" | grep -q "types/dup" + echo "$output" | grep -q "storage/dup" +} + +@test "plugin list: marks built-in, trusted, and UNTRUSTED states" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + bash "$SCRIPTS/plugin.sh" trust types/foo + mk_ext_type "$TEST_SKILL_DIR/plugins" qux + run bash "$SCRIPTS/plugin.sh" list + [ "$status" -eq 0 ] + echo "$output" | grep -E "types/codex .*builtin" + echo "$output" | grep -E "types/foo .*trusted" + echo "$output" | grep -E "types/qux .*UNTRUSTED" +} diff --git a/tests/test_storage.bats b/tests/test_storage.bats index 43eed24..d7a14a2 100644 --- a/tests/test_storage.bats +++ b/tests/test_storage.bats @@ -35,7 +35,7 @@ teardown() { @test "storage: init-db creates the db at the overridden path (and makes the dir)" { local custom="$BATS_TEST_TMPDIR/nested/store" [ ! -d "$custom" ] - AGMSG_STORAGE_PATH="$custom" bash "$SCRIPTS/init-db.sh" + AGMSG_STORAGE_PATH="$custom" bash "$SCRIPTS/internal/init-db.sh" [ -f "$custom/messages.db" ] } diff --git a/tests/test_team.bats b/tests/test_team.bats index 3465114..5cf9e5a 100644 --- a/tests/test_team.bats +++ b/tests/test_team.bats @@ -347,3 +347,9 @@ teardown() { [ "$status" -eq 0 ] [ -f "$TEST_SKILL_DIR/teams/テストチーム/config.json" ] } + +@test "join: accepts hermes" { + run bash "$SCRIPTS/join.sh" myteam alice hermes /tmp/proj + [ "$status" -eq 0 ] + [ -f "$TEST_SKILL_DIR/teams/myteam/config.json" ] +} diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats new file mode 100644 index 0000000..209d8f8 --- /dev/null +++ b/tests/test_type_registry.bats @@ -0,0 +1,208 @@ +#!/usr/bin/env bats + +# Direct unit tests for the pluggable agent-type registry: discovery + the +# per-key manifest reader. The behavioral wiring (whoami detection, join +# whitelist, spawn dispatch, delivery routing) is covered by the existing +# whoami/join/spawn/delivery suites; these lock the registry primitives and the +# six built-in manifests themselves. +# +# setup_test_env copies scripts/ (with scripts/drivers/types/) into TEST_SKILL_DIR, so the lib +# resolves /scripts/drivers/types there. Each case sources the lib in a wiped env so +# host vars (this is a Claude Code session — CLAUDE_CODE_SESSION_ID is set) cannot +# leak into detection. + +load test_helper + +setup() { setup_test_env; } +teardown() { teardown_test_env; } + +# Write a node-launcher fixture type into TEST_SKILL_DIR/scripts/drivers/types so the suite +# exercises the spawn= (Node launcher) mechanism generically, with no dependency +# on any real external add-on: +# - "nodetype": a node-launcher type whose manifest sets spawn= to a .mjs, with +# a stub launcher file beside the manifest. +write_node_launcher_fixtures() { + local nd="$TEST_SKILL_DIR/scripts/drivers/types/nodetype" + mkdir -p "$nd" + printf 'name=nodetype\ntemplate=cmd.nodetype.md\nspawn=nodetype-launcher.mjs\n' \ + > "$nd/type.conf" + printf '// stub node launcher fixture\n' > "$nd/nodetype-launcher.mjs" +} + +@test "type-registry: known_types lists the seven built-ins" { + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u | paste -sd, -" + [ "$status" -eq 0 ] + [ "$output" = "antigravity,claude-code,codex,copilot,gemini,hermes,opencode" ] +} + +@test "type-registry: is_known_type accepts a built-in and rejects a bogus type" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_is_known_type opencode" + [ "$status" -eq 0 ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_is_known_type bogus-type" + [ "$status" -ne 0 ] +} + +@test "type-registry: type_get reads keys and returns a default for a missing one" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex template" + [ "$status" -eq 0 ]; [ "$output" = "template.md" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".codex/hooks.json" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex cli" + [ "$output" = "codex" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get gemini missingkey FALLBACK" + [ "$output" = "FALLBACK" ] +} + +@test "type-registry: template_path resolves to the type dir's template.md" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_template_path codex" + [ "$status" -eq 0 ] + [ "${output##*/types/}" = "codex/template.md" ] + [ -f "$output" ] + # Unknown type → non-zero, no path. + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_template_path bogus-type" + [ "$status" -ne 0 ] +} + +@test "type-registry: spawnable set is exactly claude-code, codex and hermes" { + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + [ \"\$(agmsg_type_get \"\$t\" spawnable)\" = yes ] && echo \"\$t\" + done <<< \"\$(agmsg_known_types | sort -u)\" | paste -sd, -" + [ "$status" -eq 0 ] + [ "$output" = "claude-code,codex,hermes" ] +} + +@test "type-registry: detection manifests carry the expected env / proc keys" { + g() { env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get $1 $2"; } + [ "$(g claude-code detect)" = "CLAUDE_CODE_SESSION_ID" ] + [ "$(g codex detect)" = "CODEX_SANDBOX CODEX_THREAD_ID" ] + [ "$(g gemini detect)" = "GEMINI_API_KEY GOOGLE_GEMINI_CLI" ] + [ "$(g antigravity detect)" = "explicit" ] + [ "$(g copilot detect)" = "explicit" ] + [ "$(g opencode detect_proc)" = "opencode opencode-*" ] +} + +@test "type-registry: whoami detects codex end-to-end from CODEX_THREAD_ID" { + # Join a codex agent so whoami has a registration to report, then call it with + # no explicit type: detection must pick codex from the manifest's detect= key. + bash "$SCRIPTS/join.sh" myteam bob codex "$BATS_TEST_TMPDIR" >/dev/null + run env -i PATH="$PATH" CODEX_THREAD_ID=x bash "$SCRIPTS/whoami.sh" "$BATS_TEST_TMPDIR" + [ "$status" -eq 0 ] + echo "$output" | grep -q "type=codex" +} + +@test "type-registry: env-detection precedence is claude-code < codex < gemini" { + # Reproduce whoami's manifest-driven env sweep (sorted order) and assert the + # historical precedence: a runtime's own session var beats the GEMINI_* family, + # and detect=explicit types never win. + sweep() { + env -i PATH="$PATH" "$@" bash -c " + source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + d=\$(agmsg_type_get \"\$t\" detect) + if [ -z \"\$d\" ] || [ \"\$d\" = explicit ]; then continue; fi + for v in \$d; do [ -n \"\${!v:-}\" ] && { echo \"\$t\"; exit 0; }; done + done <<< \"\$(agmsg_known_types | sort -u)\" + echo claude-code" + } + [ "$(sweep CODEX_THREAD_ID=x)" = codex ] + [ "$(sweep GEMINI_API_KEY=x)" = gemini ] + [ "$(sweep CLAUDE_CODE_SESSION_ID=x CODEX_THREAD_ID=y)" = claude-code ] + [ "$(sweep CODEX_SANDBOX=x GEMINI_API_KEY=y)" = codex ] + [ "$(sweep)" = claude-code ] +} + +@test "type-registry: manifests are DATA — never executed" { + # An adversarial value must be read as a literal string, not run. + local dir="$TEST_SKILL_DIR/scripts/drivers/types/evil" + mkdir -p "$dir" + printf 'name=evil\ncli=$(touch %s/PWNED)\n' "$BATS_TEST_TMPDIR" > "$dir/type.conf" + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get evil cli" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/PWNED" ] +} + +@test "type-registry: type_get returns its default under set -e + pipefail" { + # A missing key must reach the default branch even when grep exits 1 under + # pipefail (regression: the assignment used to abort silently). + run bash -c "set -euo pipefail; source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get gemini missingkey DEF; echo REACHED" + [ "$status" -eq 0 ] + echo "$output" | grep -qx "DEF" + echo "$output" | grep -qx "REACHED" +} + +@test "type-registry: detect_proc matching is independent of the caller's cwd" { + # Regression: `for p in \$pats` glob-expanded the patterns against cwd, so a + # project file like codex-helper made codex-* stop matching real codex procs. + # detect_cli_type runs the split under `set -f`; prove that makes it cwd-proof. + local proj="$BATS_TEST_TMPDIR/proj"; mkdir -p "$proj"; touch "$proj/codex-helper" "$proj/claude-x" + run env -i PATH="$PATH" bash -c "cd '$proj' + source '$SCRIPTS/lib/type-registry.sh'; set -f + pats=\$(agmsg_type_get codex detect_proc); m=no + for p in \$pats; do case codex-nightly in \$p) m=yes ;; esac; done + echo \$m" + [ "$output" = "yes" ] +} + +@test "type-registry: whoami precedence — claude-code beats codex end-to-end" { + bash "$SCRIPTS/join.sh" t alice claude-code "$BATS_TEST_TMPDIR" >/dev/null + run env -i PATH="$PATH" CLAUDE_CODE_SESSION_ID=x CODEX_THREAD_ID=y bash "$SCRIPTS/whoami.sh" "$BATS_TEST_TMPDIR" + [ "$status" -eq 0 ] + echo "$output" | grep -q "type=claude-code" +} + +@test "type-registry: refactored scripts hardcode no per-type branch" { + # join.sh and spawn.sh must be fully data-driven; whoami.sh is allowed only its + # default fallback (echo "claude-code"). Any other type literal on a non-comment + # line is a re-introduced per-type branch. + local types='claude-code|codex|gemini|antigravity|copilot|opencode|hermes' + for f in join.sh spawn.sh; do + run bash -c "sed 's/#.*//' '$SCRIPTS/$f' | grep -nE '$types' || true" + [ -z "$output" ] || { echo "hardcoded type literal in $f:"; echo "$output"; false; } + done + run bash -c "sed 's/#.*//' '$SCRIPTS/whoami.sh' | grep -nE '$types' | grep -vE 'echo \"claude-code\"' || true" + [ -z "$output" ] || { echo "unexpected type literal in whoami.sh:"; echo "$output"; false; } +} + +@test "type-registry: node-launcher type resolves its spawn launcher file" { + write_node_launcher_fixtures + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get nodetype spawn" + [ "$status" -eq 0 ] + [ "$output" = "nodetype-launcher.mjs" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir nodetype" + [ "$status" -eq 0 ] + [ -n "$output" ] + [ -f "$output/nodetype-launcher.mjs" ] +} + +@test "type-registry: spawnable set (spawnable=yes OR non-empty spawn=) includes nodetype" { + write_node_launcher_fixtures + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + if [ \"\$(agmsg_type_get \"\$t\" spawnable)\" = yes ] || [ -n \"\$(agmsg_type_get \"\$t\" spawn)\" ]; then + echo \"\$t\" + fi + done <<< \"\$(agmsg_known_types | sort -u)\" | paste -sd, -" + [ "$status" -eq 0 ] + echo "$output" | tr ',' '\n' | grep -qx nodetype + echo "$output" | tr ',' '\n' | grep -qx claude-code + echo "$output" | tr ',' '\n' | grep -qx codex +} + +@test "spawn: a spawn= node-launcher type clears the spawnable gate" { + # Regression: the gate honoured only spawnable=yes while spawnable_types() also + # counts spawn=, so a node-launcher type (nodetype: spawn=, no spawnable=yes) + # was rejected as 'not supported' yet listed as supported. It must clear the + # gate (it then fails later for lack of a team/terminal — that's expected). + write_node_launcher_fixtures + run "$SCRIPTS/spawn.sh" nodetype someagent --project "$BATS_TEST_TMPDIR" + [ "$status" -ne 0 ] + ! echo "$output" | grep -q "is not supported by spawn yet" + ! echo "$output" | grep -q "unknown agent type" +} diff --git a/tests/test_watch_once.bats b/tests/test_watch_once.bats index 3e29b9f..4bb5903 100644 --- a/tests/test_watch_once.bats +++ b/tests/test_watch_once.bats @@ -14,7 +14,7 @@ teardown() { } @test "watch-once: exits 2 on timeout when no unread inbound exists" { - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -22,7 +22,7 @@ teardown() { @test "watch-once: reports existing unread inbound without marking it read" { bash "$SCRIPTS/send.sh" team bob alice "hello pending" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 0 ] [[ "$output" =~ "status=pending" ]] [[ "$output" =~ "count=1" ]] @@ -36,7 +36,7 @@ teardown() { bash "$SCRIPTS/send.sh" team bob alice "read already" >/dev/null bash "$SCRIPTS/inbox.sh" team alice >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -44,13 +44,13 @@ teardown() { @test "watch-once: ignores messages addressed to another agent" { bash "$SCRIPTS/send.sh" team alice bob "for bob" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @test "watch-once: detects a message that arrives after it starts" { - bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ + bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ >"$TEST_SKILL_DIR/watch-once.out" 2>"$TEST_SKILL_DIR/watch-once.err" & local pid=$! sleep 1 @@ -67,7 +67,7 @@ teardown() { bash "$SCRIPTS/actas-claim.sh" "$PROJ" codex alice other-sid >/dev/null bash "$SCRIPTS/send.sh" team bob alice "locked out" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 1 ] [[ "$output" =~ "no available subscription" ]] } diff --git a/tests/test_windows_powershell.bats b/tests/test_windows_powershell.bats index 48184d4..6d00744 100644 --- a/tests/test_windows_powershell.bats +++ b/tests/test_windows_powershell.bats @@ -26,7 +26,7 @@ powershell_bin() { @test "windows powershell launcher source does not hardcode team or agent names" { local launcher="$REPO_ROOT/scripts/windows/agmsg.ps1" - local dispatcher="$REPO_ROOT/scripts/dispatch.sh" + local dispatcher="$REPO_ROOT/scripts/windows/dispatch.sh" [ -f "$launcher" ] [ -f "$dispatcher" ] ! grep -q "AGMSG_TEAM.*emeria" "$launcher"