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.
+
+
+
+
**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 @@
+
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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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