From b700f583a198a8a21c1f96add8e911b17290873f Mon Sep 17 00:00:00 2001 From: lucianlamp Date: Thu, 18 Jun 2026 10:51:00 +0900 Subject: [PATCH 01/27] feat(types): pluggable agent-type registry Discover agent types from types//type.conf manifests instead of hardcoded case arms across whoami/join/spawn/delivery, so adding a type is a manifest + template (cf. the 8-file OpenCode addition #136). Detection (detect=/detect_proc=), the join whitelist, spawn (direct-CLI cli= and Node-launcher spawn=), and delivery hooks routing (hooks_file=) all read the registry. Manifests are read-only key=value DATA, never sourced. External add-on types plug in via a spawn= Node launcher (universal --name/--team/--project/--initial-input flags) and aliases= (a type owns another's spawn name), needing no built-in edits. The six built-in types are converted with zero behavior change; full suite 296 -> 312. Co-Authored-By: Claude Opus 4.8 --- docs/agent-types.md | 110 ++++++++++++++++++ install.sh | 7 +- scripts/delivery.sh | 23 ++-- scripts/join.sh | 21 ++-- scripts/lib/type-registry.sh | 134 +++++++++++++++++++++ scripts/spawn.sh | 102 ++++++++++++---- scripts/whoami.sh | 107 +++++++++-------- tests/test_helper.bash | 5 + tests/test_type_registry.bats | 211 ++++++++++++++++++++++++++++++++++ types/antigravity/type.conf | 6 + types/claude-code/type.conf | 9 ++ types/codex/type.conf | 9 ++ types/copilot/type.conf | 6 + types/gemini/type.conf | 7 ++ types/opencode/type.conf | 6 + 15 files changed, 676 insertions(+), 87 deletions(-) create mode 100644 docs/agent-types.md create mode 100644 scripts/lib/type-registry.sh create mode 100644 tests/test_type_registry.bats create mode 100644 types/antigravity/type.conf create mode 100644 types/claude-code/type.conf create mode 100644 types/codex/type.conf create mode 100644 types/copilot/type.conf create mode 100644 types/gemini/type.conf create mode 100644 types/opencode/type.conf diff --git a/docs/agent-types.md b/docs/agent-types.md new file mode 100644 index 0000000..ba5fb55 --- /dev/null +++ b/docs/agent-types.md @@ -0,0 +1,110 @@ +# 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**, not an edit across +`whoami.sh`, `join.sh`, `spawn.sh`, and `delivery.sh`. + +## The manifest + +Each type has `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 in `templates/` (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 | +| `aliases` | — | spawn names this type OWNS (reverse lookup): a bare name listed here resolves to this type as its spawn target | +| `hooks_file` | yes | project-relative delivery hooks file (e.g. `.codex/hooks.json`) | +| `monitor` | — | `yes` if the type exposes a Monitor tool; `spawn` skips the readiness wait when `no` | + +> 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. The hook +*format* (settings JSON vs. rule markdown vs. …) is still per-type code in +`delivery.sh` (`apply_settings_*`); the manifest drives the file **path** and the +allowed modes. + +### 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 +(under `${AGMSG_HOME:-$HOME/.config/agmsg}/types`) with no built-in edits. + +A type can also **own another type's spawn name** by listing it in its `aliases=` +key. The lookup is a **reverse** one: `agmsg_type_alias_for ` returns the +registered type that lists `` in its own `aliases=`. This is how an external +add-on such as `codex-app-server` plugs in as the spawn target for the bare +`codex` name — it claims `aliases=codex` in *its own* manifest, so spawning `codex` +dispatches to the add-on's launcher **without editing the built-in `codex` +manifest**. The alias ships and is removed with the add-on, and a stderr warning is +emitted if more than one type claims the same name. + +## Adding a type + +1. Create `types//type.conf` with at least `name`, `template`, and + `hooks_file` (add `detect`/`detect_proc` for auto-detection, and `cli` + + `spawnable=yes` if `spawn.sh` should launch it). +2. Add the command template `templates/cmd..md`. +3. If the type needs a delivery hook format that doesn't exist yet, add an + `apply_settings_` path in `delivery.sh`. Reusing an existing format 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 `types/` are the reference. For instance +`types/codex/type.conf`: + +``` +name=codex +template=cmd.codex.md +cli=codex +spawnable=yes +detect=CODEX_SANDBOX CODEX_THREAD_ID +detect_proc=codex codex-* +hooks_file=.codex/hooks.json +monitor=no +``` diff --git a/install.sh b/install.sh index e1a4c17..ce08986 100755 --- a/install.sh +++ b/install.sh @@ -250,6 +250,9 @@ if [ "$UPDATE_ONLY" = true ]; then 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. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" + # Ship the agent-type manifests so the type registry resolves types post-install. + mkdir -p "$SKILL_DIR/types" + cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" for tmpl in "$SCRIPT_DIR/templates/"cmd.*.md; do sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$tmpl" > "$SKILL_DIR/templates/$(basename "$tmpl")" done @@ -306,7 +309,7 @@ SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" # --- Install skill --- echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." -mkdir -p "$SKILL_DIR"/{scripts,templates,db,agents} +mkdir -p "$SKILL_DIR"/{scripts,templates,types,db,agents} # SKILL.md is generated from the agent-specific command template. SKILL_TEMPLATE="cmd.codex.md" @@ -320,6 +323,8 @@ 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. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" +# Ship the agent-type manifests so the type registry resolves types post-install. +cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" # Replace placeholder in templates with actual skill name for tmpl in "$SCRIPT_DIR/templates/"cmd.*.md; do diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 3e62e7d..23e61c8 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -39,18 +39,27 @@ RUN_DIR="$SKILL_DIR/run" . "$SCRIPT_DIR/lib/instance-id.sh" # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/node.sh" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/type-registry.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 ;; + local rel + rel="$(agmsg_type_get "$type" hooks_file)" + if [ -z "$rel" ]; then + echo "Unknown agent type: $type" >&2 + return 1 + fi + # 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 + echo "$project/$rel" } sql_readfile_path() { diff --git a/scripts/join.sh b/scripts/join.sh index 00f34d9..78f6435 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 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 +# (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/type-registry.sh b/scripts/lib/type-registry.sh new file mode 100644 index 0000000..09d4e5c --- /dev/null +++ b/scripts/lib/type-registry.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Agent-type registry. +# +# Agent types are discovered from `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. +# +# Search order: +# 1. in-tree built-ins: /types +# 2. external add-ons: ${AGMSG_HOME:-$HOME/.config/agmsg}/types +# Built-in names are reserved; if the same name appears in both, the in-tree one +# wins (listed first). +# +# 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)" + +# Echo the type search directories, one per line (in-tree first). +_agmsg_type_search_dirs() { + local root="${AGMSG_TYPES_ROOT:-}" + if [ -z "$root" ]; then + # this lib lives at /scripts/lib/type-registry.sh -> up two = + root="$(cd "$_AGMSG_REGISTRY_LIB_DIR/../.." 2>/dev/null && pwd)" + fi + [ -n "$root" ] && printf '%s\n' "$root/types" + # ${HOME:-} keeps this safe under `set -u` with an empty environment. + local ext="${AGMSG_HOME:-${HOME:-}/.config/agmsg}/types" + [ -n "$root" ] && [ "$ext" = "$root/types" ] || printf '%s\n' "$ext" +} + +# Echo the directory holding /type.conf, or return 1. +agmsg_type_dir() { + local want="$1" d + while IFS= read -r d; do + [ -n "$d" ] || continue + [ -f "$d/$want/type.conf" ] && { printf '%s\n' "$d/$want"; return 0; } + 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" +} + +# 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 +} + +# Reverse spawn-alias lookup: echo the registered type that CLAIMS as a +# spawn alias — i.e. lists in its OWN manifest's `aliases=` key — or +# nothing. This lets a type (e.g. an external add-on) own the alias in its own +# manifest instead of editing another type's manifest, so the alias ships and is +# removed with that type. Deterministic: first match in sorted type order wins; a +# stderr warning is emitted if more than one type claims . +agmsg_type_alias_for() { + local want="$1" t found="" extra="" + while IFS= read -r t; do + [ -n "$t" ] || continue + if agmsg_type_has "$t" aliases "$want"; then + if [ -z "$found" ]; then found="$t"; else extra="$extra $t"; fi + fi + done <&2 + fi + [ -n "$found" ] && printf '%s\n' "$found" + return 0 +} diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 2b25365..4235258 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. @@ -52,6 +54,8 @@ SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # actas-lock.sh requires SKILL_DIR 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" die() { echo "spawn: $*" >&2; exit 1; } @@ -62,13 +66,40 @@ NAME="${2:-}" [ -n "$NAME" ] || die "Usage: spawn.sh [options]" shift 2 || true -case "$AGENT_TYPE" in - claude-code|codex) ;; - gemini|antigravity|copilot|opencode) - die "agent type '$AGENT_TYPE' is not supported by spawn yet (supported: claude-code, codex)" ;; - *) - die "unknown agent type '$AGENT_TYPE' (supported: claude-code, codex)" ;; -esac +# A type may be a spawn ALIAS owned by another type (which lists this name in its +# own `aliases=` key); redirect via the registry's reverse lookup. Lets an +# external add-on own e.g. the `codex` spawn name without editing a built-in. +if alias="$(agmsg_type_alias_for "$AGENT_TYPE")" && [ -n "$alias" ]; then + AGENT_TYPE="$alias" +fi + +# A type is spawnable iff its manifest declares `spawnable=yes` (direct-CLI) OR a +# `spawn=` node launcher. The error lists the computed spawnable set from the +# registry — no type name is hardcoded here. +spawnable_types() { + local t + while IFS= read -r t; do + [ -n "$t" ] || continue + # `if` (not `&& printf`) so a non-spawnable last type does not leave the loop + # — and thus the function — with a non-zero status, which `set -e`+pipefail + # would turn into a silent exit at the `SUPPORTED_LIST=$(...)` assignment. + if [ "$(agmsg_type_get "$t" spawnable)" = "yes" ] || [ -n "$(agmsg_type_get "$t" spawn)" ]; then + printf '%s\n' "$t" + fi + done </dev/null 2>&1 \ - || die "'$CLI_BIN' not found on PATH — install the ${AGENT_TYPE} CLI first" +# --- Resolve the launch method from the manifest --- +# A non-empty `spawn=` launcher means this type runs via a Node launcher (e.g. an +# external add-on); otherwise it is a direct-CLI launch. The `cli=` binary is +# REQUIRED for direct-CLI types and OPTIONAL for node launchers (which resolve +# their own runtime). No per-type case — all data-driven from the manifest. +SPAWN_LAUNCHER="$(agmsg_type_get "$AGENT_TYPE" spawn)" +CLI_BIN="$(agmsg_type_get "$AGENT_TYPE" cli)" +CLI_PATH="" +if [ -n "$CLI_BIN" ]; then + command -v "$CLI_BIN" >/dev/null 2>&1 \ + || die "'$CLI_BIN' not found on PATH — install the ${AGENT_TYPE} CLI first" + CLI_PATH="$(command -v "$CLI_BIN")" +elif [ -z "$SPAWN_LAUNCHER" ]; then + die "agent type '$AGENT_TYPE' manifest declares neither a 'cli' binary nor a 'spawn' launcher" +fi +# Resolve the node launcher path from the manifest (not hardcoded), if any. +SPAWN_AGENT="" +if [ -n "$SPAWN_LAUNCHER" ]; then + NODE_BIN="${AGMSG_NODE_BIN:-$(command -v node 2>/dev/null || true)}" + [ -n "$NODE_BIN" ] || die "'node' not found on PATH — spawning '$AGENT_TYPE' requires Node.js" + type_dir="$(agmsg_type_dir "$AGENT_TYPE")" \ + || die "agent type '$AGENT_TYPE' is not registered (no types/$AGENT_TYPE/type.conf)" + SPAWN_AGENT="$type_dir/$SPAWN_LAUNCHER" + [ -f "$SPAWN_AGENT" ] || die "spawn launcher not found for '$AGENT_TYPE': $SPAWN_AGENT" +fi # --- Resolve the team to join into --- # When --team is omitted, derive it from any team that already has an agent @@ -224,7 +273,18 @@ BOOT="$BOOT.command" { echo '#!/usr/bin/env bash' printf 'cd %q || exit 1\n' "$PROJECT" - printf '%q %q\n' "$CLI_BIN" "$ACTAS_PROMPT" + if [ -n "$SPAWN_AGENT" ]; then + # Node-launcher path: pass the universal agmsg context + the actas prompt. + # Type-specific config is the launcher's own default/env, so core stays + # generic and names no add-on. + printf '%q %q \\\n' "$NODE_BIN" "$SPAWN_AGENT" + printf ' --name %q \\\n' "$NAME" + printf ' --team %q \\\n' "$TEAM" + printf ' --project %q \\\n' "$PROJECT" + printf ' --initial-input %q\n' "$ACTAS_PROMPT" + else + printf '%q %q\n' "$CLI_BIN" "$ACTAS_PROMPT" + fi echo 'rm -f "$0" 2>/dev/null' # self-clean once the agent exits echo 'exec "${SHELL:-/bin/bash}" -i' } > "$BOOT" @@ -368,12 +428,12 @@ place_and_launch() { # receiving. Block until that appears so the leader doesn't send a job into the # cold-start window (before the watcher attaches) and lose it. # -# Codex has no Monitor/watcher, so nothing would ever touch the sentinel — -# skip the wait for codex (its receive is poll-based anyway). +# Types without a Monitor (manifest `monitor=no`) never touch the readiness +# sentinel, so skip the wait for them (their receive is poll-based anyway). READY_PATH="$(agmsg_ready_path "$TEAM" "$NAME")" -if [ "$AGENT_TYPE" = "codex" ] && [ "$WAIT_READY" = "1" ]; then +if [ "$(agmsg_type_get "$AGENT_TYPE" monitor)" = "no" ] && [ "$WAIT_READY" = "1" ]; then WAIT_READY=0 - echo "spawn: codex has no Monitor — skipping readiness wait (--no-wait implied)" >&2 + echo "spawn: '$AGENT_TYPE' has no Monitor — skipping readiness wait (--no-wait implied)" >&2 fi # Clear any stale sentinel before launching so we only observe THIS spawn's diff --git a/scripts/whoami.sh b/scripts/whoami.sh index 0acf3ae..3077b0d 100755 --- a/scripts/whoami.sh +++ b/scripts/whoami.sh @@ -11,57 +11,66 @@ set -euo pipefail # type: claude-code, codex, gemini, antigravity, copilot, opencode # If type is omitted, auto-detect from env vars and process tree. -# Auto-detect CLI type from environment variables and process tree -detect_cli_type() { - # 1. Check environment variables. Order matters: prefer the env vars that - # the runtime *itself* exports for its own session over the env vars users - # commonly set globally for unrelated reasons. CLAUDE_CODE_SESSION_ID and - # CODEX_SANDBOX / CODEX_THREAD_ID are set by their runtimes only. The - # GEMINI_API_KEY family is also routinely set by users of the Gemini API - # SDK without the Gemini CLI being involved, so it goes last. - if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then - echo "claude-code" - return 0 - fi - - if [ -n "${CODEX_SANDBOX:-}" ] || [ -n "${CODEX_THREAD_ID:-}" ]; then - echo "codex" - return 0 - fi - - if [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GOOGLE_GEMINI_CLI:-}" ]; then - echo "gemini" - return 0 - fi - - # 2. Fall back to process tree detection - local pid=$$ - local max_depth=10 - local depth=0 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/type-registry.sh" +# Auto-detect CLI type from environment variables and the process tree, driven by +# the per-type manifests' `detect=` (env-var names) and `detect_proc=` (process +# name globs) keys — no hardcoded type list lives here. +detect_cli_type() { + # `detect=` / `detect_proc=` tokens are split with `read -ra` (IFS word-split, + # NO pathname expansion) rather than an unquoted `for x in $list` — a file in + # the caller's cwd matching a pattern like `claude-*` must not glob-eat the + # pattern. (Plain `set -f` can't be used here: agmsg_known_types discovers types + # via a `*/` glob that must keep working.) + + # 1. Environment variables. Sorted registry order preserves the historical + # precedence: a runtime's own session vars (CLAUDE_CODE_SESSION_ID, CODEX_*) are + # checked before the GEMINI_* family, which users also set for the SDK without + # the CLI. `detect=explicit` (and types with no detect=) are never auto-detected. + local _t _v _detect _toks + while IFS= read -r _t; do + [ -n "$_t" ] || continue + _detect="$(agmsg_type_get "$_t" detect)" + if [ -z "$_detect" ] || [ "$_detect" = "explicit" ]; then + continue + fi + read -ra _toks <<<"$_detect" + for _v in "${_toks[@]}"; do + if [ -n "${!_v:-}" ]; then + echo "$_t" + return 0 + fi + done + done </dev/null | xargs basename 2>/dev/null || true) - - case "$proc_name" in - codex|codex-*) - echo "codex" - return 0 - ;; - gemini|gemini-*) - echo "gemini" - return 0 - ;; - claude|claude-code|claude-*) - echo "claude-code" - return 0 - ;; - opencode|opencode-*) - echo "opencode" - return 0 - ;; - esac + if [ -n "$proc_name" ]; then + while IFS= read -r _t; do + [ -n "$_t" ] || continue + _pats="$(agmsg_type_get "$_t" detect_proc)" + [ -n "$_pats" ] || continue + read -ra _toks <<<"$_pats" + for _pat in "${_toks[@]}"; do + # $_pat is intentionally an UNQUOTED glob pattern matched against the + # process name; read -ra already kept it out of pathname expansion. + # shellcheck disable=SC2254 + case "$proc_name" in + $_pat) echo "$_t"; return 0 ;; + esac + done + done </dev/null | tr -d ' ' || true) @@ -75,7 +84,7 @@ detect_cli_type() { PROJECT_PATH="${1:?Usage: whoami.sh [type]}" AGENT_TYPE="${2:-$(detect_cli_type)}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# SCRIPT_DIR is already resolved above (before sourcing the type registry). SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TEAMS_DIR="$SCRIPT_DIR/../teams" diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 676b7c2..cd8852d 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -11,6 +11,11 @@ setup_test_env() { chmod +x "$TEST_SKILL_DIR/scripts/"*.sh chmod +x "$TEST_SKILL_DIR/scripts/"*.js 2>/dev/null || true + # Copy the agent-type manifests so the type registry resolves types inside the + # sandbox (scripts/lib/type-registry.sh reads /types//type.conf). + mkdir -p "$TEST_SKILL_DIR/types" + cp -R "$BATS_TEST_DIRNAME"/../types/. "$TEST_SKILL_DIR/types/" + # Initialize DB bash "$TEST_SKILL_DIR/scripts/init-db.sh" diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats new file mode 100644 index 0000000..6060c43 --- /dev/null +++ b/tests/test_type_registry.bats @@ -0,0 +1,211 @@ +#!/usr/bin/env bats + +# Direct unit tests for the pluggable agent-type registry: discovery + the +# per-key manifest reader. The behavioral wiring (whoami detection, join +# whitelist, spawn dispatch, delivery routing) is covered by the existing +# whoami/join/spawn/delivery suites; these lock the registry primitives and the +# six built-in manifests themselves. +# +# setup_test_env copies both scripts/ and types/ into TEST_SKILL_DIR, so the lib +# resolves /types there. Each case sources the lib in a wiped env so +# host vars (this is a Claude Code session — CLAUDE_CODE_SESSION_ID is set) cannot +# leak into detection. + +load test_helper + +setup() { setup_test_env; } +teardown() { teardown_test_env; } + +# Write two fixture types into TEST_SKILL_DIR/types so the suite exercises the +# node-launcher (spawn=) + alias mechanism generically, with no dependency on any +# real external add-on: +# - "nodetype": a node-launcher type. Its manifest sets spawn= to a .mjs and +# OWNS the spawn name "aliassrc" via aliases=. A stub launcher file sits +# beside the manifest. +# - "aliassrc": a bare type whose spawn name is reverse-claimed by nodetype. +write_node_launcher_fixtures() { + local nd="$TEST_SKILL_DIR/types/nodetype" + mkdir -p "$nd" + printf 'name=nodetype\ntemplate=cmd.nodetype.md\nspawn=nodetype-launcher.mjs\naliases=aliassrc\n' \ + > "$nd/type.conf" + printf '// stub node launcher fixture\n' > "$nd/nodetype-launcher.mjs" + + local as="$TEST_SKILL_DIR/types/aliassrc" + mkdir -p "$as" + printf 'name=aliassrc\n' > "$as/type.conf" +} + +@test "type-registry: known_types lists the six built-ins" { + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u | paste -sd, -" + [ "$status" -eq 0 ] + [ "$output" = "antigravity,claude-code,codex,copilot,gemini,opencode" ] +} + +@test "type-registry: is_known_type accepts a built-in and rejects a bogus type" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_is_known_type opencode" + [ "$status" -eq 0 ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_is_known_type bogus-type" + [ "$status" -ne 0 ] +} + +@test "type-registry: type_get reads keys and returns a default for a missing one" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex template" + [ "$status" -eq 0 ]; [ "$output" = "cmd.codex.md" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".codex/hooks.json" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex cli" + [ "$output" = "codex" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get gemini missingkey FALLBACK" + [ "$output" = "FALLBACK" ] +} + +@test "type-registry: spawnable set is exactly claude-code and codex" { + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + [ \"\$(agmsg_type_get \"\$t\" spawnable)\" = yes ] && echo \"\$t\" + done <<< \"\$(agmsg_known_types | sort -u)\" | paste -sd, -" + [ "$status" -eq 0 ] + [ "$output" = "claude-code,codex" ] +} + +@test "type-registry: detection manifests carry the expected env / proc keys" { + g() { env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get $1 $2"; } + [ "$(g claude-code detect)" = "CLAUDE_CODE_SESSION_ID" ] + [ "$(g codex detect)" = "CODEX_SANDBOX CODEX_THREAD_ID" ] + [ "$(g gemini detect)" = "GEMINI_API_KEY GOOGLE_GEMINI_CLI" ] + [ "$(g antigravity detect)" = "explicit" ] + [ "$(g copilot detect)" = "explicit" ] + [ "$(g opencode detect_proc)" = "opencode opencode-*" ] +} + +@test "type-registry: whoami detects codex end-to-end from CODEX_THREAD_ID" { + # Join a codex agent so whoami has a registration to report, then call it with + # no explicit type: detection must pick codex from the manifest's detect= key. + bash "$SCRIPTS/join.sh" myteam bob codex "$BATS_TEST_TMPDIR" >/dev/null + run env -i PATH="$PATH" CODEX_THREAD_ID=x bash "$SCRIPTS/whoami.sh" "$BATS_TEST_TMPDIR" + [ "$status" -eq 0 ] + echo "$output" | grep -q "type=codex" +} + +@test "type-registry: env-detection precedence is claude-code < codex < gemini" { + # Reproduce whoami's manifest-driven env sweep (sorted order) and assert the + # historical precedence: a runtime's own session var beats the GEMINI_* family, + # and detect=explicit types never win. + sweep() { + env -i PATH="$PATH" "$@" bash -c " + source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + d=\$(agmsg_type_get \"\$t\" detect) + if [ -z \"\$d\" ] || [ \"\$d\" = explicit ]; then continue; fi + for v in \$d; do [ -n \"\${!v:-}\" ] && { echo \"\$t\"; exit 0; }; done + done <<< \"\$(agmsg_known_types | sort -u)\" + echo claude-code" + } + [ "$(sweep CODEX_THREAD_ID=x)" = codex ] + [ "$(sweep GEMINI_API_KEY=x)" = gemini ] + [ "$(sweep CLAUDE_CODE_SESSION_ID=x CODEX_THREAD_ID=y)" = claude-code ] + [ "$(sweep CODEX_SANDBOX=x GEMINI_API_KEY=y)" = codex ] + [ "$(sweep)" = claude-code ] +} + +@test "type-registry: manifests are DATA — never executed" { + # An adversarial value must be read as a literal string, not run. + local dir="$TEST_SKILL_DIR/types/evil" + mkdir -p "$dir" + printf 'name=evil\ncli=$(touch %s/PWNED)\n' "$BATS_TEST_TMPDIR" > "$dir/type.conf" + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get evil cli" + [ "$status" -eq 0 ] + [ ! -e "$BATS_TEST_TMPDIR/PWNED" ] +} + +@test "type-registry: type_get returns its default under set -e + pipefail" { + # A missing key must reach the default branch even when grep exits 1 under + # pipefail (regression: the assignment used to abort silently). + run bash -c "set -euo pipefail; source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get gemini missingkey DEF; echo REACHED" + [ "$status" -eq 0 ] + echo "$output" | grep -qx "DEF" + echo "$output" | grep -qx "REACHED" +} + +@test "type-registry: detect_proc matching is independent of the caller's cwd" { + # Regression: `for p in \$pats` glob-expanded the patterns against cwd, so a + # project file like codex-helper made codex-* stop matching real codex procs. + # detect_cli_type runs the split under `set -f`; prove that makes it cwd-proof. + local proj="$BATS_TEST_TMPDIR/proj"; mkdir -p "$proj"; touch "$proj/codex-helper" "$proj/claude-x" + run env -i PATH="$PATH" bash -c "cd '$proj' + source '$SCRIPTS/lib/type-registry.sh'; set -f + pats=\$(agmsg_type_get codex detect_proc); m=no + for p in \$pats; do case codex-nightly in \$p) m=yes ;; esac; done + echo \$m" + [ "$output" = "yes" ] +} + +@test "type-registry: whoami precedence — claude-code beats codex end-to-end" { + bash "$SCRIPTS/join.sh" t alice claude-code "$BATS_TEST_TMPDIR" >/dev/null + run env -i PATH="$PATH" CLAUDE_CODE_SESSION_ID=x CODEX_THREAD_ID=y bash "$SCRIPTS/whoami.sh" "$BATS_TEST_TMPDIR" + [ "$status" -eq 0 ] + echo "$output" | grep -q "type=claude-code" +} + +@test "type-registry: refactored scripts hardcode no per-type branch" { + # join.sh and spawn.sh must be fully data-driven; whoami.sh is allowed only its + # default fallback (echo "claude-code"). Any other type literal on a non-comment + # line is a re-introduced per-type branch. + local types='claude-code|codex|gemini|antigravity|copilot|opencode' + for f in join.sh spawn.sh; do + run bash -c "sed 's/#.*//' '$SCRIPTS/$f' | grep -nE '$types' || true" + [ -z "$output" ] || { echo "hardcoded type literal in $f:"; echo "$output"; false; } + done + run bash -c "sed 's/#.*//' '$SCRIPTS/whoami.sh' | grep -nE '$types' | grep -vE 'echo \"claude-code\"' || true" + [ -z "$output" ] || { echo "unexpected type literal in whoami.sh:"; echo "$output"; false; } +} + +@test "type-registry: node-launcher type resolves its spawn launcher file" { + write_node_launcher_fixtures + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get nodetype spawn" + [ "$status" -eq 0 ] + [ "$output" = "nodetype-launcher.mjs" ] + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir nodetype" + [ "$status" -eq 0 ] + [ -n "$output" ] + [ -f "$output/nodetype-launcher.mjs" ] +} + +@test "type-registry: spawnable set (spawnable=yes OR non-empty spawn=) includes nodetype" { + write_node_launcher_fixtures + run env -i PATH="$PATH" bash -c \ + "source '$SCRIPTS/lib/type-registry.sh' + while IFS= read -r t; do + [ -n \"\$t\" ] || continue + if [ \"\$(agmsg_type_get \"\$t\" spawnable)\" = yes ] || [ -n \"\$(agmsg_type_get \"\$t\" spawn)\" ]; then + echo \"\$t\" + fi + done <<< \"\$(agmsg_known_types | sort -u)\" | paste -sd, -" + [ "$status" -eq 0 ] + echo "$output" | tr ',' '\n' | grep -qx nodetype + echo "$output" | tr ',' '\n' | grep -qx claude-code + echo "$output" | tr ',' '\n' | grep -qx codex +} + +@test "type-registry: alias reverse-resolves aliassrc to its owning type nodetype" { + write_node_launcher_fixtures + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_alias_for aliassrc" + [ "$status" -eq 0 ] + [ "$output" = "nodetype" ] +} + +@test "spawn: a spawn= node-launcher type clears the spawnable gate" { + # Regression: the gate honoured only spawnable=yes while spawnable_types() also + # counts spawn=, so a node-launcher type (nodetype: spawn=, no spawnable=yes) + # was rejected as 'not supported' yet listed as supported. It must clear the + # gate (it then fails later for lack of a team/terminal — that's expected). + write_node_launcher_fixtures + run "$SCRIPTS/spawn.sh" nodetype someagent --project "$BATS_TEST_TMPDIR" + [ "$status" -ne 0 ] + ! echo "$output" | grep -q "is not supported by spawn yet" + ! echo "$output" | grep -q "unknown agent type" +} diff --git a/types/antigravity/type.conf b/types/antigravity/type.conf new file mode 100644 index 0000000..86132d3 --- /dev/null +++ b/types/antigravity/type.conf @@ -0,0 +1,6 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=antigravity +template=cmd.antigravity.md +detect=explicit +hooks_file=.agent/rules/agmsg.md +monitor=no diff --git a/types/claude-code/type.conf b/types/claude-code/type.conf new file mode 100644 index 0000000..8385a63 --- /dev/null +++ b/types/claude-code/type.conf @@ -0,0 +1,9 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=claude-code +template=cmd.claude-code.md +cli=claude +spawnable=yes +detect=CLAUDE_CODE_SESSION_ID +detect_proc=claude claude-code claude-* +hooks_file=.claude/settings.local.json +monitor=yes diff --git a/types/codex/type.conf b/types/codex/type.conf new file mode 100644 index 0000000..ed4b471 --- /dev/null +++ b/types/codex/type.conf @@ -0,0 +1,9 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=codex +template=cmd.codex.md +cli=codex +spawnable=yes +detect=CODEX_SANDBOX CODEX_THREAD_ID +detect_proc=codex codex-* +hooks_file=.codex/hooks.json +monitor=no diff --git a/types/copilot/type.conf b/types/copilot/type.conf new file mode 100644 index 0000000..b449dad --- /dev/null +++ b/types/copilot/type.conf @@ -0,0 +1,6 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=copilot +template=cmd.copilot.md +detect=explicit +hooks_file=.github/hooks/agmsg.json +monitor=no diff --git a/types/gemini/type.conf b/types/gemini/type.conf new file mode 100644 index 0000000..f1dc14d --- /dev/null +++ b/types/gemini/type.conf @@ -0,0 +1,7 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=gemini +template=cmd.gemini.md +detect=GEMINI_API_KEY GOOGLE_GEMINI_CLI +detect_proc=gemini gemini-* +hooks_file=.agent/rules/agmsg.md +monitor=no diff --git a/types/opencode/type.conf b/types/opencode/type.conf new file mode 100644 index 0000000..63dd887 --- /dev/null +++ b/types/opencode/type.conf @@ -0,0 +1,6 @@ +# agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. +name=opencode +template=cmd.opencode.md +detect_proc=opencode opencode-* +hooks_file=.opencode/rules/agmsg.md +monitor=no From 314879e1336f86d3d81bd441d83743dbbb43ccab Mon Sep 17 00:00:00 2001 From: lucianlamp Date: Thu, 18 Jun 2026 11:30:02 +0900 Subject: [PATCH 02/27] feat(types): drop the aliases= auto-redirect; explicit type selection only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent types are selected explicitly: 'spawn codex' is plain codex, 'spawn codex-app-server' is the add-on — no automatic alias hijacks a built-in's spawn name. Removes agmsg_type_alias_for, spawn.sh's reverse-alias resolution, the aliases= manifest key (schema + node-launcher docs), and the alias fixture/test. The node-launcher (spawn=) plug point for external add-ons stays. Full suite 311/311; built-in behavior unchanged. Co-Authored-By: Claude Opus 4.8 --- docs/agent-types.md | 10 ---------- scripts/lib/type-registry.sh | 24 ------------------------ scripts/spawn.sh | 7 ------- tests/test_type_registry.bats | 25 ++++++------------------- 4 files changed, 6 insertions(+), 60 deletions(-) diff --git a/docs/agent-types.md b/docs/agent-types.md index ba5fb55..a010240 100644 --- a/docs/agent-types.md +++ b/docs/agent-types.md @@ -23,7 +23,6 @@ cannot execute code. Multi-value keys are whitespace-separated. | `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 | -| `aliases` | — | spawn names this type OWNS (reverse lookup): a bare name listed here resolves to this type as its spawn target | | `hooks_file` | yes | project-relative delivery hooks file (e.g. `.codex/hooks.json`) | | `monitor` | — | `yes` if the type exposes a Monitor tool; `spawn` skips the readiness wait when `no` | @@ -71,15 +70,6 @@ vars) is the launcher's **own default / environment** — agmsg core never names add-on. This is what lets a node-launcher type ship entirely outside the agmsg tree (under `${AGMSG_HOME:-$HOME/.config/agmsg}/types`) with no built-in edits. -A type can also **own another type's spawn name** by listing it in its `aliases=` -key. The lookup is a **reverse** one: `agmsg_type_alias_for ` returns the -registered type that lists `` in its own `aliases=`. This is how an external -add-on such as `codex-app-server` plugs in as the spawn target for the bare -`codex` name — it claims `aliases=codex` in *its own* manifest, so spawning `codex` -dispatches to the add-on's launcher **without editing the built-in `codex` -manifest**. The alias ships and is removed with the add-on, and a stderr warning is -emitted if more than one type claims the same name. - ## Adding a type 1. Create `types//type.conf` with at least `name`, `template`, and diff --git a/scripts/lib/type-registry.sh b/scripts/lib/type-registry.sh index 09d4e5c..c1f9441 100644 --- a/scripts/lib/type-registry.sh +++ b/scripts/lib/type-registry.sh @@ -108,27 +108,3 @@ agmsg_type_has() { done return 1 } - -# Reverse spawn-alias lookup: echo the registered type that CLAIMS as a -# spawn alias — i.e. lists in its OWN manifest's `aliases=` key — or -# nothing. This lets a type (e.g. an external add-on) own the alias in its own -# manifest instead of editing another type's manifest, so the alias ships and is -# removed with that type. Deterministic: first match in sorted type order wins; a -# stderr warning is emitted if more than one type claims . -agmsg_type_alias_for() { - local want="$1" t found="" extra="" - while IFS= read -r t; do - [ -n "$t" ] || continue - if agmsg_type_has "$t" aliases "$want"; then - if [ -z "$found" ]; then found="$t"; else extra="$extra $t"; fi - fi - done <&2 - fi - [ -n "$found" ] && printf '%s\n' "$found" - return 0 -} diff --git a/scripts/spawn.sh b/scripts/spawn.sh index 4235258..cec3713 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -66,13 +66,6 @@ NAME="${2:-}" [ -n "$NAME" ] || die "Usage: spawn.sh [options]" shift 2 || true -# A type may be a spawn ALIAS owned by another type (which lists this name in its -# own `aliases=` key); redirect via the registry's reverse lookup. Lets an -# external add-on own e.g. the `codex` spawn name without editing a built-in. -if alias="$(agmsg_type_alias_for "$AGENT_TYPE")" && [ -n "$alias" ]; then - AGENT_TYPE="$alias" -fi - # A type is spawnable iff its manifest declares `spawnable=yes` (direct-CLI) OR a # `spawn=` node launcher. The error lists the computed spawnable set from the # registry — no type name is hardcoded here. diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index 6060c43..4512451 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -16,23 +16,17 @@ load test_helper setup() { setup_test_env; } teardown() { teardown_test_env; } -# Write two fixture types into TEST_SKILL_DIR/types so the suite exercises the -# node-launcher (spawn=) + alias mechanism generically, with no dependency on any -# real external add-on: -# - "nodetype": a node-launcher type. Its manifest sets spawn= to a .mjs and -# OWNS the spawn name "aliassrc" via aliases=. A stub launcher file sits -# beside the manifest. -# - "aliassrc": a bare type whose spawn name is reverse-claimed by nodetype. +# Write a node-launcher fixture type into TEST_SKILL_DIR/types so the suite +# exercises the spawn= (Node launcher) mechanism generically, with no dependency +# on any real external add-on: +# - "nodetype": a node-launcher type whose manifest sets spawn= to a .mjs, with +# a stub launcher file beside the manifest. write_node_launcher_fixtures() { local nd="$TEST_SKILL_DIR/types/nodetype" mkdir -p "$nd" - printf 'name=nodetype\ntemplate=cmd.nodetype.md\nspawn=nodetype-launcher.mjs\naliases=aliassrc\n' \ + printf 'name=nodetype\ntemplate=cmd.nodetype.md\nspawn=nodetype-launcher.mjs\n' \ > "$nd/type.conf" printf '// stub node launcher fixture\n' > "$nd/nodetype-launcher.mjs" - - local as="$TEST_SKILL_DIR/types/aliassrc" - mkdir -p "$as" - printf 'name=aliassrc\n' > "$as/type.conf" } @test "type-registry: known_types lists the six built-ins" { @@ -191,13 +185,6 @@ write_node_launcher_fixtures() { echo "$output" | tr ',' '\n' | grep -qx codex } -@test "type-registry: alias reverse-resolves aliassrc to its owning type nodetype" { - write_node_launcher_fixtures - run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_alias_for aliassrc" - [ "$status" -eq 0 ] - [ "$output" = "nodetype" ] -} - @test "spawn: a spawn= node-launcher type clears the spawnable gate" { # Regression: the gate honoured only spawnable=yes while spawnable_types() also # counts spawn=, so a node-launcher type (nodetype: spawn=, no spawnable=yes) From 23f1b8f719d10ab802a88162c2b2104a294671e3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 20 Jun 2026 22:58:07 -0700 Subject: [PATCH 03/27] refactor(scripts): move the codex subsystem into scripts/codex/ Group the codex-only scripts (codex-bridge.js, codex-bridge-launcher.sh, codex-monitor.sh, codex-shim.sh, codex-shim-install.sh, watch-once.sh) under scripts/codex/ so scripts/ top holds the externally-invoked command surface plus cross-cutting helpers, not a per-agent subsystem. Path follow-through: moved scripts resolve SKILL_DIR as ../.. and reference top-level siblings (delivery.sh, identities.sh, inbox.sh, send.sh, lib/) via ../; codex-internal siblings stay SCRIPT_DIR-relative. External callers updated (session-start.sh, delivery.sh), install.sh chmods scripts/codex/, tests and docs point at the new paths. No behavior change. --- README.md | 2 +- docs/codex-monitor-beta.md | 10 +++--- install.sh | 2 ++ scripts/{ => codex}/codex-bridge-launcher.sh | 6 ++-- scripts/{ => codex}/codex-bridge.js | 13 ++++---- scripts/{ => codex}/codex-monitor.sh | 4 +-- scripts/{ => codex}/codex-shim-install.sh | 0 scripts/{ => codex}/codex-shim.sh | 2 +- scripts/{ => codex}/watch-once.sh | 10 +++--- scripts/delivery.sh | 6 ++-- scripts/session-start.sh | 2 +- templates/cmd.codex.md | 4 +-- tests/test_codex_bridge.bats | 32 ++++++++++---------- tests/test_codex_shim.bats | 10 +++--- tests/test_watch_once.bats | 12 ++++---- 15 files changed, 59 insertions(+), 56 deletions(-) rename scripts/{ => codex}/codex-bridge-launcher.sh (95%) rename scripts/{ => codex}/codex-bridge.js (98%) rename scripts/{ => codex}/codex-monitor.sh (97%) rename scripts/{ => codex}/codex-shim-install.sh (100%) rename scripts/{ => codex}/codex-shim.sh (97%) rename scripts/{ => codex}/watch-once.sh (93%) diff --git a/README.md b/README.md index 1baa5aa..619a42e 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,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/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 diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index cd7bfc4..7460281 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/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/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/codex/codex-bridge.js) +- [Monitor launcher](../scripts/codex/codex-monitor.sh) +- [Codex shim](../scripts/codex/codex-shim.sh) diff --git a/install.sh b/install.sh index ce08986..020c881 100755 --- a/install.sh +++ b/install.sh @@ -279,6 +279,7 @@ if [ "$UPDATE_ONLY" = true ]; then 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/codex/"* 2>/dev/null || true install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" @@ -333,6 +334,7 @@ done 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/codex/"* 2>/dev/null || true install_windows_helpers # Marker file for uninstall detection diff --git a/scripts/codex-bridge-launcher.sh b/scripts/codex/codex-bridge-launcher.sh similarity index 95% rename from scripts/codex-bridge-launcher.sh rename to scripts/codex/codex-bridge-launcher.sh index 47a55e3..2862e52 100755 --- a/scripts/codex-bridge-launcher.sh +++ b/scripts/codex/codex-bridge-launcher.sh @@ -16,20 +16,20 @@ 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" PROJECT_HASH="$(printf '%s' "$PROJECT" | shasum | awk '{print $1}')" REQUEST_FILE="$RUN_DIR/codex-bridge-request.$PROJECT_HASH" # shellcheck source=lib/node.sh -source "$SCRIPT_DIR/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/codex/codex-bridge.js similarity index 98% rename from scripts/codex-bridge.js rename to scripts/codex/codex-bridge.js index dd7c7a8..7569e83 100755 --- a/scripts/codex-bridge.js +++ b/scripts/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/codex +const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", ".."); // skill root +const SCRIPTS_DIR = path.join(SKILL_DIR, "scripts"); // top-level scripts (siblings live here) const RUN_DIR = path.join(SKILL_DIR, "run"); function usage() { @@ -116,7 +117,7 @@ function parseArgs(argv) { } function runScript(script, args) { - const result = spawnSync(path.join(SCRIPT_DIR, script), args, { + const result = spawnSync(path.join(SCRIPTS_DIR, script), args, { cwd: SKILL_DIR, encoding: "utf8", }); @@ -810,8 +811,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}:`, @@ -831,7 +832,7 @@ class CodexBridge { } readInboxForPrompt() { - const result = spawnSync(path.join(SCRIPT_DIR, "inbox.sh"), [this.identity.team, this.identity.name], { + const result = spawnSync(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/codex/codex-monitor.sh similarity index 97% rename from scripts/codex-monitor.sh rename to scripts/codex/codex-monitor.sh index c43932c..d43a9d6 100755 --- a/scripts/codex-monitor.sh +++ b/scripts/codex/codex-monitor.sh @@ -8,7 +8,7 @@ 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" PROJECT="$(pwd)" @@ -124,7 +124,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/codex/codex-shim-install.sh similarity index 100% rename from scripts/codex-shim-install.sh rename to scripts/codex/codex-shim-install.sh diff --git a/scripts/codex-shim.sh b/scripts/codex/codex-shim.sh similarity index 97% rename from scripts/codex-shim.sh rename to scripts/codex/codex-shim.sh index f14daf6..672e380 100755 --- a/scripts/codex-shim.sh +++ b/scripts/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/scripts/watch-once.sh b/scripts/codex/watch-once.sh similarity index 93% rename from scripts/watch-once.sh rename to scripts/codex/watch-once.sh index 0ea1112..b2e2496 100755 --- a/scripts/watch-once.sh +++ b/scripts/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/delivery.sh b/scripts/delivery.sh index 23e61c8..43c94c3 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -530,7 +530,7 @@ 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 + if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/scripts/codex/codex-shim-install.sh" install; then echo "Codex monitor shim installed at ~/.agents/bin/codex." case ":$PATH:" in *":$HOME/.agents/bin:"*) @@ -546,7 +546,7 @@ do_set() { 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." + echo "Future Codex sessions: launch with $SKILL_DIR/scripts/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 here at enable time. @@ -589,7 +589,7 @@ do_set() { 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 " $SKILL_DIR/scripts/codex/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 diff --git a/scripts/session-start.sh b/scripts/session-start.sh index eb80440..aaaf680 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -149,7 +149,7 @@ if [ "$TYPE" = "codex" ]; then if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") else - bridge_run=("$(agmsg_resolve_node)" "$SCRIPT_DIR/codex-bridge.js") + bridge_run=("$(agmsg_resolve_node)" "$SCRIPT_DIR/codex/codex-bridge.js") fi nohup "${bridge_run[@]}" \ --project "$PROJECT" \ diff --git a/templates/cmd.codex.md b/templates/cmd.codex.md index bf9a92a..1a276a2 100644 --- a/templates/cmd.codex.md +++ b/templates/cmd.codex.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/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/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/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index a0065b7..e269490 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -16,25 +16,25 @@ teardown() { } @test "codex-bridge: help exits successfully" { - run node "$SCRIPTS/codex-bridge.js" --help + run node "$SCRIPTS/codex/codex-bridge.js" --help [ "$status" -eq 0 ] [[ "$output" =~ "Beta Codex app-server bridge" ]] } @test "codex-bridge: resolve-only prints the selected identity" { - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only + run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only [ "$status" -eq 0 ] [ "$output" = $'team\talice' ] } @test "codex-bridge: resolve-only rejects ambiguous identities" { - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --resolve-only + run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --resolve-only [ "$status" -eq 1 ] [[ "$output" =~ "multiple identities match" ]] } @test "codex-bridge: rejects unsupported app-server endpoints" { - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 + run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 [ "$status" -eq 1 ] [[ "$output" =~ "supports only unix://PATH or ws://host:port" ]] } @@ -178,7 +178,7 @@ EOF sleep 0.1 done - run node "$SCRIPTS/codex-bridge.js" \ + run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "unix://$sock" --timeout 1 --interval 1 --max-wakes 1 @@ -312,7 +312,7 @@ EOF local port port="$(cat "$portfile")" - run node "$SCRIPTS/codex-bridge.js" \ + run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "ws://127.0.0.1:$port" --timeout 1 --interval 1 --max-wakes 1 @@ -328,7 +328,7 @@ EOF mkdir -p "$TEST_SKILL_DIR/run" echo "$$" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" - run node "$SCRIPTS/codex-bridge.js" --project "$PROJ" --team team --name alice + run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice [ "$status" -eq 1 ] [[ "$output" =~ "bridge already running" ]] } @@ -391,7 +391,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 [ "$status" -eq 0 ] @@ -436,7 +436,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing --timeout 20 [ "$status" -eq 0 ] @@ -484,7 +484,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 5000 --timeout 20 [ "$status" -eq 0 ] @@ -518,7 +518,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 1500 --timeout 20 [ "$status" -ne 0 ] @@ -585,7 +585,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 --inline-inbox [ "$status" -eq 0 ] @@ -648,7 +648,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 [ "$status" -eq 1 ] @@ -695,7 +695,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 1 --max-wakes 2 [ "$status" -eq 0 ] @@ -741,7 +741,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] @@ -801,7 +801,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-active --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] # not exit 1 from the stale-wake guard diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index 090f3ef..2cbd450 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -38,7 +38,7 @@ teardown() { @test "codex shim: monitor project routes resume through codex-monitor" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex-shim.sh" resume --last' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex/codex-shim.sh" resume --last' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--last>" "$CALL_LOG" @@ -47,7 +47,7 @@ teardown() { @test "codex shim: monitor project routes prompt launches through top-level codex" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex-shim.sh" "fix this"' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex/codex-shim.sh" "fix this"' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> " "$CALL_LOG" @@ -57,7 +57,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" resume --last + run bash "$SCRIPTS/codex/codex-shim.sh" resume --last [ "$status" -eq 0 ] grep -q "real-codex <--last>" "$CALL_LOG" @@ -68,7 +68,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" exec echo hi + run bash "$SCRIPTS/codex/codex-shim.sh" exec echo hi [ "$status" -eq 0 ] grep -q "real-codex " "$CALL_LOG" @@ -79,7 +79,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex-shim.sh" --cd "$TEST_PROJECT" resume + run bash "$SCRIPTS/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--cd> <$TEST_PROJECT>" "$CALL_LOG" diff --git a/tests/test_watch_once.bats b/tests/test_watch_once.bats index 3e29b9f..c9fb239 100644 --- a/tests/test_watch_once.bats +++ b/tests/test_watch_once.bats @@ -14,7 +14,7 @@ teardown() { } @test "watch-once: exits 2 on timeout when no unread inbound exists" { - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -22,7 +22,7 @@ teardown() { @test "watch-once: reports existing unread inbound without marking it read" { bash "$SCRIPTS/send.sh" team bob alice "hello pending" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 0 ] [[ "$output" =~ "status=pending" ]] [[ "$output" =~ "count=1" ]] @@ -36,7 +36,7 @@ teardown() { bash "$SCRIPTS/send.sh" team bob alice "read already" >/dev/null bash "$SCRIPTS/inbox.sh" team alice >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -44,13 +44,13 @@ teardown() { @test "watch-once: ignores messages addressed to another agent" { bash "$SCRIPTS/send.sh" team alice bob "for bob" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @test "watch-once: detects a message that arrives after it starts" { - bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ + bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ >"$TEST_SKILL_DIR/watch-once.out" 2>"$TEST_SKILL_DIR/watch-once.err" & local pid=$! sleep 1 @@ -67,7 +67,7 @@ teardown() { bash "$SCRIPTS/actas-claim.sh" "$PROJ" codex alice other-sid >/dev/null bash "$SCRIPTS/send.sh" team bob alice "locked out" >/dev/null - run bash "$SCRIPTS/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 1 ] [[ "$output" =~ "no available subscription" ]] } From fa31c764f26aeaea648389e25c3015ed87b797f9 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 20 Jun 2026 23:05:10 -0700 Subject: [PATCH 04/27] refactor(scripts): move init-db to internal/, dispatch to windows/, drop hook.sh - scripts/internal/init-db.sh: DB schema init is a pure internal helper (callers: send.sh, install.sh). Not baked into any hook config, so it is safe to move. - scripts/windows/dispatch.sh: the command dispatcher is used only by the Windows launcher (agmsg.ps1); co-locate it. dispatch resolves top-level commands via ..; agmsg.ps1 now finds it beside itself (PSScriptRoot). - remove scripts/hook.sh: a long-deprecated alias for 'delivery.sh set turn|off' that only printed a deprecation notice and was never invoked by any runtime/hook. Drops tests/test_hook.bats, the hook.sh blocks in test_delivery.bats, and the README/design/SKILL mentions. check-inbox.sh deliberately stays at scripts/ top: delivery.sh bakes its absolute path into the Stop hook in users' settings, so moving it would break turn-mode delivery on upgrade until 'delivery.sh set' is re-run (same class as session-start/session-end). --- README.md | 2 - SKILL.md | 3 +- docs/design.md | 1 - install.sh | 2 +- scripts/hook.sh | 23 ----- scripts/{ => internal}/init-db.sh | 2 +- scripts/send.sh | 4 +- scripts/windows/agmsg.ps1 | 2 +- scripts/{ => windows}/dispatch.sh | 2 +- tests/test_delivery.bats | 31 ------ tests/test_dispatch.bats | 16 +-- tests/test_hook.bats | 160 ----------------------------- tests/test_install.bats | 4 +- tests/test_windows_powershell.bats | 2 +- 14 files changed, 18 insertions(+), 236 deletions(-) delete mode 100755 scripts/hook.sh rename scripts/{ => internal}/init-db.sh (97%) rename scripts/{ => windows}/dispatch.sh (99%) delete mode 100644 tests/test_hook.bats diff --git a/README.md b/README.md index 619a42e..f192d96 100644 --- a/README.md +++ b/README.md @@ -309,8 +309,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?** 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/design.md b/docs/design.md index d432037..5446ff7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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/install.sh b/install.sh index 020c881..c1a9eda 100755 --- a/install.sh +++ b/install.sh @@ -346,7 +346,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 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/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/windows/agmsg.ps1 b/scripts/windows/agmsg.ps1 index 30070e4..883aea9 100644 --- a/scripts/windows/agmsg.ps1 +++ b/scripts/windows/agmsg.ps1 @@ -98,7 +98,7 @@ function Test-SqliteAvailable { $script:Bash = Find-GitBash Test-SqliteAvailable -$dispatcher = Join-Path $script:ScriptsDir 'dispatch.sh' +$dispatcher = Join-Path $PSScriptRoot 'dispatch.sh' if (-not (Test-Path -LiteralPath $dispatcher)) { throw "Missing agmsg dispatcher: $dispatcher" } diff --git a/scripts/dispatch.sh b/scripts/windows/dispatch.sh similarity index 99% rename from scripts/dispatch.sh rename to scripts/windows/dispatch.sh index d96d9d5..a256d6d 100755 --- a/scripts/dispatch.sh +++ b/scripts/windows/dispatch.sh @@ -69,7 +69,7 @@ fi run_script() { local script="$1" shift - bash "$SCRIPT_DIR/$script" "$@" + bash "$SCRIPT_DIR/../$script" "$@" } require_args() { diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 478a19a..fe68e10 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -172,21 +172,6 @@ settings_file() { [[ "$output" =~ "mode: off" ]] } -# --- hook.sh backward compat --- - -@test "hook.sh on delegates to delivery set turn" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" 2>&1 - has_check_inbox "$(settings_file)" - ! has_session_start "$(settings_file)" -} - -@test "hook.sh off delegates to delivery set off" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" 2>&1 - bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" 2>&1 - ! has_check_inbox "$(settings_file)" - ! has_session_start "$(settings_file)" -} - # --- rejects unknown mode --- @test "delivery set: rejects unknown mode" { @@ -466,22 +451,6 @@ JSON kill "$alive_pid" 2>/dev/null || true } -# --- hook.sh deprecation notice --- - -@test "hook.sh on prints a deprecation notice on stderr" { - run bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - # Combined stderr+stdout is captured by `run` — assert the notice appears. - [[ "$output" =~ "deprecated" ]] -} - -@test "hook.sh off prints a deprecation notice on stderr" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" >/dev/null - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "deprecated" ]] -} - # --- emit_monitor_directive idempotency --- @test "emit monitor directive: skips when a live watcher already exists for this session" { diff --git a/tests/test_dispatch.bats b/tests/test_dispatch.bats index 889de26..3fdcd22 100644 --- a/tests/test_dispatch.bats +++ b/tests/test_dispatch.bats @@ -17,19 +17,19 @@ teardown() { } @test "dispatch: explicit team and agent can check inbox" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_BOB" --team demo --agent bob -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_BOB" --team demo --agent bob -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @test "dispatch: environment team and agent can check inbox" { - run env AGMSG_TEAM=demo AGMSG_AGENT=bob bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_BOB" -- inbox + run env AGMSG_TEAM=demo AGMSG_AGENT=bob bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_BOB" -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @test "dispatch: whoami single identity resolves inbox" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- inbox [ "$status" -eq 0 ] [[ "$output" =~ "No new messages." ]] } @@ -38,7 +38,7 @@ teardown() { bash "$SCRIPTS/join.sh" many first codex "$PROJECT_MULTI" bash "$SCRIPTS/join.sh" many second codex "$PROJECT_MULTI" - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_MULTI" -- inbox + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_MULTI" -- inbox [ "$status" -eq 2 ] [[ "$output" =~ "multiple=true" ]] [[ "$output" =~ "agmsg -Team -Agent inbox" ]] @@ -46,20 +46,20 @@ teardown() { @test "dispatch: send then history preserves Japanese, quotes, and emoji" { local message='確認しました "quoted" emoji 🚀' - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo --agent alice -- send bob "$message" + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo --agent alice -- send bob "$message" [ "$status" -eq 0 ] - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo -- history + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" --team demo -- history [ "$status" -eq 0 ] [[ "$output" =~ "$message" ]] } @test "dispatch: codex mode off and turn delegate to delivery" { - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode off + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode off [ "$status" -eq 0 ] [[ "$output" =~ "Delivery mode set to 'off'" ]] - run bash "$SCRIPTS/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode turn + run bash "$SCRIPTS/windows/dispatch.sh" --type codex --project "$PROJECT_ALICE" -- mode turn [ "$status" -eq 0 ] [[ "$output" =~ "Delivery mode set to 'turn'" ]] } diff --git a/tests/test_hook.bats b/tests/test_hook.bats deleted file mode 100644 index 1276c2e..0000000 --- a/tests/test_hook.bats +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { - setup_test_env - # Pin bare instance-id keying (#93) so check-inbox's watcher-defer and actas - # owner checks key on the raw session_id these tests pass — deterministic in - # CI and when the suite runs under an agent process. - export AGMSG_AGENT_PID="" - export TEST_PROJECT="$(mktemp -d)" -} - -teardown() { - rm -rf "$TEST_PROJECT" - teardown_test_env -} - -# --- hook.sh on --- - -@test "hook on: creates settings.local.json" { - run bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [ -f "$TEST_PROJECT/.claude/settings.local.json" ] - [[ "$output" =~ "Delivery mode set to 'turn'" ]] -} - -@test "hook on: settings contains Stop hook with check-inbox" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "Stop" ]] - [[ "$content" =~ "check-inbox.sh" ]] -} - -@test "hook on: is idempotent" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local count=$(python3 -c " -import json -d = json.load(open('$TEST_PROJECT/.claude/settings.local.json')) -print(len(d['hooks']['Stop'])) -") - [ "$count" -eq 1 ] -} - -@test "hook on: preserves existing settings" { - mkdir -p "$TEST_PROJECT/.claude" - echo '{"permissions":{"allow":["Bash"]}}' > "$TEST_PROJECT/.claude/settings.local.json" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "permissions" ]] - [[ "$content" =~ "Stop" ]] -} - -@test "hook on: handles path with spaces" { - local spaced="$(mktemp -d)/my project" - mkdir -p "$spaced" - run bash "$SCRIPTS/hook.sh" on claude-code "$spaced" - [ "$status" -eq 0 ] - [ -f "$spaced/.claude/settings.local.json" ] - rm -rf "$spaced" -} - -@test "hook on: codex creates hooks.json" { - run bash "$SCRIPTS/hook.sh" on codex "$TEST_PROJECT" - [ "$status" -eq 0 ] - [ -f "$TEST_PROJECT/.codex/hooks.json" ] - [[ "$output" =~ "Delivery mode set to 'turn'" ]] -} - -@test "hook off: codex removes hook" { - bash "$SCRIPTS/hook.sh" on codex "$TEST_PROJECT" - run bash "$SCRIPTS/hook.sh" off codex "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] -} - -# --- hook.sh off --- - -@test "hook off: removes hook from settings" { - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ ! "$content" =~ "Stop" ]] -} - -@test "hook off: reports no hook when not configured" { - run bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - [ "$status" -eq 0 ] - [[ "$output" =~ "Delivery mode set to 'off'" ]] -} - -@test "hook off: preserves other settings" { - mkdir -p "$TEST_PROJECT/.claude" - echo '{"permissions":{"allow":["Bash"]}}' > "$TEST_PROJECT/.claude/settings.local.json" - bash "$SCRIPTS/hook.sh" on claude-code "$TEST_PROJECT" - bash "$SCRIPTS/hook.sh" off claude-code "$TEST_PROJECT" - local content=$(cat "$TEST_PROJECT/.claude/settings.local.json") - [[ "$content" =~ "permissions" ]] - [[ ! "$content" =~ "Stop" ]] -} - -# --- check-inbox.sh --- - -@test "check-inbox: respects cooldown" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - # First call creates marker - echo '{}' | bash "$SCRIPTS/check-inbox.sh" claude-code "$TEST_PROJECT" - # Send message after marker - bash "$SCRIPTS/send.sh" testteam bob alice "hello" - # Second call within cooldown should skip - run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "check-inbox: exits silently when not joined" { - run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code /tmp/nowhere" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "check-inbox: exits silently when stop_hook_active" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "hello" - run bash -c 'echo "{\"stop_hook_active\":true}" | bash "'"$SCRIPTS"'/check-inbox.sh" claude-code "'"$TEST_PROJECT"'"' - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -# Stop-hook delivery should respect actas exclusivity locks the same way -# the Monitor-mode watcher does (#62). If a peer session owns (team, alice), -# this session must not consume alice's inbox here — that would defeat the -# whole exclusivity guarantee for codex / claude-code-turn delivery paths. -@test "check-inbox: skips a team when (team, agent) is locked by another live session" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "should not be delivered here" - - setup_live_owner "$TEST_SKILL_DIR/run" "peer-sid" - echo "peer-sid" > "$TEST_SKILL_DIR/run/actas.testteam__alice.session" - - run bash -c "echo '{\"session_id\":\"mine-sid\"}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - # The message should NOT surface — no "block decision" payload, no body. - [[ ! "$output" =~ "should not be delivered here" ]] -} - -@test "check-inbox: still delivers when the lock is owned by this session" { - bash "$SCRIPTS/join.sh" testteam alice claude-code "$TEST_PROJECT" - bash "$SCRIPTS/send.sh" testteam bob alice "I am the owner" - - setup_live_owner "$TEST_SKILL_DIR/run" "mine-sid" - echo "mine-sid" > "$TEST_SKILL_DIR/run/actas.testteam__alice.session" - - run bash -c "echo '{\"session_id\":\"mine-sid\"}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$TEST_PROJECT'" - [ "$status" -eq 0 ] - [[ "$output" =~ "I am the owner" ]] -} diff --git a/tests/test_install.bats b/tests/test_install.bats index df84921..17346e7 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -199,7 +199,7 @@ wait_for_pidfile_pid() { [ ! -f "$FAKE_HOME/.agents/bin/sqlite3" ] [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/agmsg.ps1" ] [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/install-agmsg.ps1" ] - [ -f "$FAKE_HOME/.agents/skills/msg/scripts/dispatch.sh" ] + [ -f "$FAKE_HOME/.agents/skills/msg/scripts/windows/dispatch.sh" ] } @test "install --update: removes legacy Windows runner and sqlite shim" { @@ -234,7 +234,7 @@ PS1 [ -f "$SK/scripts/windows/agmsg.ps1" ] [ -f "$SK/scripts/windows/install-agmsg.ps1" ] - [ -f "$SK/scripts/dispatch.sh" ] + [ -f "$SK/scripts/windows/dispatch.sh" ] [ ! -f "$SK/scripts/windows/agmsg-run.sh" ] [ ! -f "$SK/scripts/windows/sqlite3-shim.sh" ] } diff --git a/tests/test_windows_powershell.bats b/tests/test_windows_powershell.bats index 48184d4..6d00744 100644 --- a/tests/test_windows_powershell.bats +++ b/tests/test_windows_powershell.bats @@ -26,7 +26,7 @@ powershell_bin() { @test "windows powershell launcher source does not hardcode team or agent names" { local launcher="$REPO_ROOT/scripts/windows/agmsg.ps1" - local dispatcher="$REPO_ROOT/scripts/dispatch.sh" + local dispatcher="$REPO_ROOT/scripts/windows/dispatch.sh" [ -f "$launcher" ] [ -f "$dispatcher" ] ! grep -q "AGMSG_TEAM.*emeria" "$launcher" From 907c37000f63782ffee8170b54b46a247492d9e5 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 20 Jun 2026 23:14:06 -0700 Subject: [PATCH 05/27] test(helper): follow init-db move + chmod scripts/codex in setup_test_env setup_test_env ran scripts/init-db.sh (now scripts/internal/init-db.sh), so every test using it failed at setup. Point it at the new path and chmod scripts/codex/ so the relocated codex scripts stay executable in the test skill dir. --- tests/test_helper.bash | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_helper.bash b/tests/test_helper.bash index cd8852d..e5c45cc 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -10,6 +10,7 @@ setup_test_env() { cp -R "$BATS_TEST_DIRNAME"/../scripts/. "$TEST_SKILL_DIR/scripts/" chmod +x "$TEST_SKILL_DIR/scripts/"*.sh chmod +x "$TEST_SKILL_DIR/scripts/"*.js 2>/dev/null || true + chmod +x "$TEST_SKILL_DIR/scripts/codex/"* 2>/dev/null || true # Copy the agent-type manifests so the type registry resolves types inside the # sandbox (scripts/lib/type-registry.sh reads /types//type.conf). @@ -17,7 +18,7 @@ setup_test_env() { cp -R "$BATS_TEST_DIRNAME"/../types/. "$TEST_SKILL_DIR/types/" # Initialize DB - bash "$TEST_SKILL_DIR/scripts/init-db.sh" + bash "$TEST_SKILL_DIR/scripts/internal/init-db.sh" # Convenience vars export SCRIPTS="$TEST_SKILL_DIR/scripts" From 6a94ddab88452023b525301c448090ae6ce31a0a Mon Sep 17 00:00:00 2001 From: fujibee Date: Sat, 20 Jun 2026 23:45:26 -0700 Subject: [PATCH 06/27] refactor(delivery): extract hook JSON primitives into lib/hooks-json.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the SQLite/JSON read-modify-write helpers (sql_readfile_path, strip_agmsg_event_file, windows_wrap, add_event_entry_file, prune_empty_hooks_file) out of delivery.sh into a dedicated lib module. Pure code move — no behavior change. delivery.sh sources it after SKILL_NAME is set, matching the existing lib convention. delivery.sh: 754 -> 588 lines. --- scripts/delivery.sh | 174 +---------------------------------- scripts/lib/hooks-json.sh | 185 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 170 deletions(-) create mode 100644 scripts/lib/hooks-json.sh diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 43c94c3..64c4bec 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -41,6 +41,10 @@ RUN_DIR="$SKILL_DIR/run" . "$SCRIPT_DIR/lib/node.sh" # shellcheck disable=SC1091 . "$SCRIPT_DIR/lib/type-registry.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" # 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 @@ -62,176 +66,6 @@ resolve_hooks_file() { echo "$project/$rel" } -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=$(sqlite3 :memory: " - 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'" - 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=$(sqlite3 :memory: " - 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=$(sqlite3 :memory: " - 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" diff --git a/scripts/lib/hooks-json.sh b/scripts/lib/hooks-json.sh new file mode 100644 index 0000000..226af4f --- /dev/null +++ b/scripts/lib/hooks-json.sh @@ -0,0 +1,185 @@ +#!/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=$(sqlite3 :memory: " + 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'" + 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=$(sqlite3 :memory: " + 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=$(sqlite3 :memory: " + 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" +} From a2fa65022830d24c8e157ea065d8a9728a0c4593 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:02:38 -0700 Subject: [PATCH 07/27] refactor(delivery): per-type delivery as a Template Method plug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delivery.sh is now a template: the default agmsg_delivery_apply_default writes the JSON event-hooks (claude-code, codex), and a type may override it by shipping types//_delivery.sh that defines agmsg_delivery_apply. apply_settings() sources the plug when present, else calls the default — so adding a type's delivery behavior is dropping one file, no edits to delivery.sh. - scripts/lib/delivery-rulefile.sh: shared rule-file behavior (markdown rules file). gemini/antigravity delegate to it in a one-line _delivery.sh. - types/opencode/_delivery.sh, types/copilot/_delivery.sh: bespoke (turn|off only; copilot writes JSON) — behavior preserved from the old apply_settings_*. - removes apply_settings_{gemini,copilot,opencode} and the type dispatch from delivery.sh. test_delivery.bats: 87/87 (behavior unchanged). --- scripts/delivery.sh | 157 ++++++------------------------- scripts/lib/delivery-rulefile.sh | 38 ++++++++ types/antigravity/_delivery.sh | 4 + types/copilot/_delivery.sh | 54 +++++++++++ types/gemini/_delivery.sh | 7 ++ types/opencode/_delivery.sh | 36 +++++++ 6 files changed, 166 insertions(+), 130 deletions(-) create mode 100644 scripts/lib/delivery-rulefile.sh create mode 100644 types/antigravity/_delivery.sh create mode 100644 types/copilot/_delivery.sh create mode 100644 types/gemini/_delivery.sh create mode 100644 types/opencode/_delivery.sh diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 64c4bec..af35a64 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -45,6 +45,10 @@ RUN_DIR="$SKILL_DIR/run" # 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 @@ -66,139 +70,13 @@ resolve_hooks_file() { echo "$project/$rel" } -apply_settings_opencode() { +# 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 types//_delivery.sh. +agmsg_delivery_apply_default() { 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 - ;; - 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 -} - -apply_settings_copilot() { - 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=$(sqlite3 :memory: "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") @@ -254,6 +132,25 @@ apply_settings() { mv "$tmp_state" "$hooks_file" } +# Apply delivery settings for a type (Template Method). A type may ship a +# delivery plug at types//_delivery.sh that defines agmsg_delivery_apply to +# override the default JSON event-hooks behavior; otherwise the default is used. +apply_settings() { + local type="$1" + local project="$2" + local mode="$3" + + local tdir + tdir="$(agmsg_type_dir "$type" 2>/dev/null || true)" + if [ -n "$tdir" ] && [ -f "$tdir/_delivery.sh" ]; then + # shellcheck disable=SC1090 + . "$tdir/_delivery.sh" + agmsg_delivery_apply "$type" "$project" "$mode" + else + agmsg_delivery_apply_default "$type" "$project" "$mode" + fi +} + CODEX_MONITOR_DOC_URL="https://github.com/fujibee/agmsg/blob/main/docs/codex-monitor-beta.md" emit_monitor_directive() { diff --git a/scripts/lib/delivery-rulefile.sh b/scripts/lib/delivery-rulefile.sh new file mode 100644 index 0000000..6484753 --- /dev/null +++ b/scripts/lib/delivery-rulefile.sh @@ -0,0 +1,38 @@ +#!/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 (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 +} diff --git a/types/antigravity/_delivery.sh b/types/antigravity/_delivery.sh new file mode 100644 index 0000000..36937e3 --- /dev/null +++ b/types/antigravity/_delivery.sh @@ -0,0 +1,4 @@ +#!/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 "$@"; } diff --git a/types/copilot/_delivery.sh b/types/copilot/_delivery.sh new file mode 100644 index 0000000..a1266e8 --- /dev/null +++ b/types/copilot/_delivery.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# copilot delivery plug — JSON hooks file (.github/hooks/agmsg.json), turn|off +# only (no Monitor-tool equivalent). 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") + + # 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=$(sqlite3 :memory: "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 +} diff --git a/types/gemini/_delivery.sh b/types/gemini/_delivery.sh new file mode 100644 index 0000000..ed62514 --- /dev/null +++ b/types/gemini/_delivery.sh @@ -0,0 +1,7 @@ +#!/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 "$@"; } diff --git a/types/opencode/_delivery.sh b/types/opencode/_delivery.sh new file mode 100644 index 0000000..9750004 --- /dev/null +++ b/types/opencode/_delivery.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# opencode delivery plug — markdown rule-file, but turn|off only (no Monitor-tool +# equivalent, so monitor/both are rejected). 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") + + 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 + + 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 +} From 801259a9fe3bde164c76462f49bb82b0921f2032 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:16:24 -0700 Subject: [PATCH 08/27] test(storage): follow init-db move to scripts/internal/ --- tests/test_storage.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_storage.bats b/tests/test_storage.bats index 43eed24..d7a14a2 100644 --- a/tests/test_storage.bats +++ b/tests/test_storage.bats @@ -35,7 +35,7 @@ teardown() { @test "storage: init-db creates the db at the overridden path (and makes the dir)" { local custom="$BATS_TEST_TMPDIR/nested/store" [ ! -d "$custom" ] - AGMSG_STORAGE_PATH="$custom" bash "$SCRIPTS/init-db.sh" + AGMSG_STORAGE_PATH="$custom" bash "$SCRIPTS/internal/init-db.sh" [ -f "$custom/messages.db" ] } From ccf738dff076567b1877f450b0dadcb768485d40 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:16:24 -0700 Subject: [PATCH 09/27] refactor(check-inbox): drive Stop-hook status output from manifest stop_output= The codex|copilot JSON status (continue:true) on empty checks (cooldown / no new messages) was a hardcoded type list. Read it from the type manifest instead: stop_output=json emits the JSON status object, otherwise stay silent (claude-code). codex and copilot set stop_output=json. No behavior change (test_delivery 87/87, incl. the cooldown-status assertion). --- scripts/check-inbox.sh | 33 +++++++++++++-------------------- types/codex/type.conf | 1 + types/copilot/type.conf | 1 + 3 files changed, 15 insertions(+), 20 deletions(-) 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/types/codex/type.conf b/types/codex/type.conf index ed4b471..6afb847 100644 --- a/types/codex/type.conf +++ b/types/codex/type.conf @@ -7,3 +7,4 @@ detect=CODEX_SANDBOX CODEX_THREAD_ID detect_proc=codex codex-* hooks_file=.codex/hooks.json monitor=no +stop_output=json diff --git a/types/copilot/type.conf b/types/copilot/type.conf index b449dad..2c3a789 100644 --- a/types/copilot/type.conf +++ b/types/copilot/type.conf @@ -4,3 +4,4 @@ template=cmd.copilot.md detect=explicit hooks_file=.github/hooks/agmsg.json monitor=no +stop_output=json From 1c2d7db77735fe8aa7e57c4201cb319223aff6a7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:35:11 -0700 Subject: [PATCH 10/27] refactor(session-start): extract codex bridge handoff into a type plug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the delivery.sh Template Method pattern to session-start.sh: it now defines a default no-op agmsg_session_start and, when types//_session-start.sh exists, sources it (in the script's context) and calls the override. The codex-only bridge block — thread-id resolution, app-server discovery, and the bridge/request-file launch — moves verbatim into types/codex/_session-start.sh. Behaviour is unchanged; the codex tests (session-start + bridge) stay green. --- scripts/session-start.sh | 137 +++++----------------------------- types/codex/_session-start.sh | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 120 deletions(-) create mode 100644 types/codex/_session-start.sh diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 839e069..2c2cbd3 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/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 +# 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/types/codex/_session-start.sh b/types/codex/_session-start.sh new file mode 100644 index 0000000..236e54d --- /dev/null +++ b/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)" "$SCRIPT_DIR/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 +} From c14d1f30307cfbd85007b254600dab657fc79345 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:42:07 -0700 Subject: [PATCH 11/27] fix(test): follow init-db move to internal/ in the Windows PowerShell smoke smoke_windows_powershell.ps1 ran scripts/init-db.sh (now scripts/internal/) so the Windows-tests CI leg failed with exit 127. Point it at the new path. Also fix the docs/design.md script-table entry. --- docs/design.md | 2 +- tests/smoke_windows_powershell.ps1 | 2 +- templates/cmd.antigravity.md => types/antigravity/template.md | 0 templates/cmd.claude-code.md => types/claude-code/template.md | 0 templates/cmd.codex.md => types/codex/template.md | 0 templates/cmd.copilot.md => types/copilot/template.md | 0 templates/cmd.gemini.md => types/gemini/template.md | 0 templates/cmd.opencode.md => types/opencode/template.md | 0 8 files changed, 2 insertions(+), 2 deletions(-) rename templates/cmd.antigravity.md => types/antigravity/template.md (100%) rename templates/cmd.claude-code.md => types/claude-code/template.md (100%) rename templates/cmd.codex.md => types/codex/template.md (100%) rename templates/cmd.copilot.md => types/copilot/template.md (100%) rename templates/cmd.gemini.md => types/gemini/template.md (100%) rename templates/cmd.opencode.md => types/opencode/template.md (100%) diff --git a/docs/design.md b/docs/design.md index 5446ff7..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) | diff --git a/tests/smoke_windows_powershell.ps1 b/tests/smoke_windows_powershell.ps1 index 8d19abe..d5ba426 100644 --- a/tests/smoke_windows_powershell.ps1 +++ b/tests/smoke_windows_powershell.ps1 @@ -73,7 +73,7 @@ try { $projectBobBash = (& $bash -lc 'cygpath -u "$1"' agmsg-path $projectBob | Out-String).Trim() $projectMultiBash = (& $bash -lc 'cygpath -u "$1"' agmsg-path $projectMulti | Out-String).Trim() - & $bash (Join-Path $scriptsDir 'init-db.sh') | Out-Null + & $bash (Join-Path (Join-Path $scriptsDir 'internal') 'init-db.sh') | Out-Null if ($LASTEXITCODE -ne 0) { throw "init-db failed: $LASTEXITCODE" } & $bash (Join-Path $scriptsDir 'join.sh') demo alice codex $projectSingleBash | Out-Null if ($LASTEXITCODE -ne 0) { throw "join alice failed: $LASTEXITCODE" } diff --git a/templates/cmd.antigravity.md b/types/antigravity/template.md similarity index 100% rename from templates/cmd.antigravity.md rename to types/antigravity/template.md diff --git a/templates/cmd.claude-code.md b/types/claude-code/template.md similarity index 100% rename from templates/cmd.claude-code.md rename to types/claude-code/template.md diff --git a/templates/cmd.codex.md b/types/codex/template.md similarity index 100% rename from templates/cmd.codex.md rename to types/codex/template.md diff --git a/templates/cmd.copilot.md b/types/copilot/template.md similarity index 100% rename from templates/cmd.copilot.md rename to types/copilot/template.md diff --git a/templates/cmd.gemini.md b/types/gemini/template.md similarity index 100% rename from templates/cmd.gemini.md rename to types/gemini/template.md diff --git a/templates/cmd.opencode.md b/types/opencode/template.md similarity index 100% rename from templates/cmd.opencode.md rename to types/opencode/template.md From 57316c4ab70c8a3fee39a7ce6c6155565f4eec49 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 00:50:55 -0700 Subject: [PATCH 12/27] refactor(types): wire SKILL templates to type-dir manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the template co-location whose file moves landed in c14d1f3: the manifest template= key (previously dead) is now the source of truth. - lib/type-registry.sh: add agmsg_type_template_path, resolving template= relative to the type dir (rejects absolute/traversal like resolve_hooks_file) - type.conf x6: template=cmd..md -> template=template.md - install.sh: source the type registry; route every SKILL.md / slash-command generation through the resolver instead of hardcoded templates/ paths; drop the write-only $SKILL_DIR/templates/ ship (no runtime consumer) — templates now travel inside types/ via the existing cp -R, as unsubstituted source data - test_type_registry.bats: expect template.md; cover the new resolver No behavior change. Full bats suite (353) green incl. an end-to-end install smoke. --- install.sh | 71 +++++++++++++++++------------------ scripts/lib/type-registry.sh | 17 +++++++++ tests/test_type_registry.bats | 12 +++++- types/antigravity/type.conf | 2 +- types/claude-code/type.conf | 2 +- types/codex/type.conf | 2 +- types/copilot/type.conf | 2 +- types/gemini/type.conf | 2 +- types/opencode/type.conf | 2 +- 9 files changed, 68 insertions(+), 44 deletions(-) diff --git a/install.sh b/install.sh index c1a9eda..62a1990 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 +# (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 + @@ -239,27 +245,24 @@ 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" + # 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) 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/) ship without enumerating files. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" - # Ship the agent-type manifests so the type registry resolves types post-install. + # Ship the agent-type manifests (and their co-located SKILL templates) so the + # type registry resolves types post-install. mkdir -p "$SKILL_DIR/types" cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" - for tmpl in "$SCRIPT_DIR/templates/"cmd.*.md; do - sed "s/__SKILL_NAME__/$SKILL_NAME/g" "$tmpl" > "$SKILL_DIR/templates/$(basename "$tmpl")" - done # 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 @@ -269,13 +272,13 @@ 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 cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh @@ -310,28 +313,22 @@ SKILL_DIR="$AGENTS_DIR/skills/$CMD_NAME" # --- Install skill --- echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." -mkdir -p "$SKILL_DIR"/{scripts,templates,types,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" +mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} + +# SKILL.md is generated from the agent-specific command template, resolved from +# the type manifest (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) 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/) ship without enumerating files. cp -R "$SCRIPT_DIR/scripts/." "$SKILL_DIR/scripts/" -# Ship the agent-type manifests so the type registry resolves types post-install. +# Ship the agent-type manifests (and their co-located SKILL templates) so the +# type registry resolves types post-install. cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" -# 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 - 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/codex/"* 2>/dev/null || true @@ -359,7 +356,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 @@ -370,7 +367,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 @@ -382,7 +379,7 @@ 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 diff --git a/scripts/lib/type-registry.sh b/scripts/lib/type-registry.sh index c1f9441..c26de00 100644 --- a/scripts/lib/type-registry.sh +++ b/scripts/lib/type-registry.sh @@ -100,6 +100,23 @@ agmsg_type_get() { 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 +# (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 diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index 4512451..3022f1a 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -45,7 +45,7 @@ write_node_launcher_fixtures() { @test "type-registry: type_get reads keys and returns a default for a missing one" { run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex template" - [ "$status" -eq 0 ]; [ "$output" = "cmd.codex.md" ] + [ "$status" -eq 0 ]; [ "$output" = "template.md" ] run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" [ "$output" = ".codex/hooks.json" ] run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex cli" @@ -54,6 +54,16 @@ write_node_launcher_fixtures() { [ "$output" = "FALLBACK" ] } +@test "type-registry: template_path resolves to the type dir's template.md" { + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_template_path codex" + [ "$status" -eq 0 ] + [ "${output##*/types/}" = "codex/template.md" ] + [ -f "$output" ] + # Unknown type → non-zero, no path. + run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_template_path bogus-type" + [ "$status" -ne 0 ] +} + @test "type-registry: spawnable set is exactly claude-code and codex" { run env -i PATH="$PATH" bash -c \ "source '$SCRIPTS/lib/type-registry.sh' diff --git a/types/antigravity/type.conf b/types/antigravity/type.conf index 86132d3..f2f4593 100644 --- a/types/antigravity/type.conf +++ b/types/antigravity/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=antigravity -template=cmd.antigravity.md +template=template.md detect=explicit hooks_file=.agent/rules/agmsg.md monitor=no diff --git a/types/claude-code/type.conf b/types/claude-code/type.conf index 8385a63..29581bc 100644 --- a/types/claude-code/type.conf +++ b/types/claude-code/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=claude-code -template=cmd.claude-code.md +template=template.md cli=claude spawnable=yes detect=CLAUDE_CODE_SESSION_ID diff --git a/types/codex/type.conf b/types/codex/type.conf index 6afb847..9633b35 100644 --- a/types/codex/type.conf +++ b/types/codex/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=codex -template=cmd.codex.md +template=template.md cli=codex spawnable=yes detect=CODEX_SANDBOX CODEX_THREAD_ID diff --git a/types/copilot/type.conf b/types/copilot/type.conf index 2c3a789..f9a82b1 100644 --- a/types/copilot/type.conf +++ b/types/copilot/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=copilot -template=cmd.copilot.md +template=template.md detect=explicit hooks_file=.github/hooks/agmsg.json monitor=no diff --git a/types/gemini/type.conf b/types/gemini/type.conf index f1dc14d..5a88b91 100644 --- a/types/gemini/type.conf +++ b/types/gemini/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=gemini -template=cmd.gemini.md +template=template.md detect=GEMINI_API_KEY GOOGLE_GEMINI_CLI detect_proc=gemini gemini-* hooks_file=.agent/rules/agmsg.md diff --git a/types/opencode/type.conf b/types/opencode/type.conf index 63dd887..9e998b3 100644 --- a/types/opencode/type.conf +++ b/types/opencode/type.conf @@ -1,6 +1,6 @@ # agmsg agent-type manifest — read-only key=value DATA. NEVER sourced. name=opencode -template=cmd.opencode.md +template=template.md detect_proc=opencode opencode-* hooks_file=.opencode/rules/agmsg.md monitor=no From 73a3d3b37b1eedab6e91db2e762d6ef13d7e75ac Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 01:06:10 -0700 Subject: [PATCH 13/27] test(windows): stage types/ in the PowerShell smoke so type checks resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PowerShell smoke hand-stages the skill dir by copying scripts/ only, but join.sh (and other commands) validate the agent type through the type registry, which resolves types//type.conf relative to the skill dir. Without types/, 'join … codex' fails with 'Unknown agent type' (exit 1). This was latent until the init-db path fix let the smoke reach the join step; copy types/ alongside scripts/ to mirror what install.sh ships. Reproduced on macOS: no types/ -> exit 1, types/ present -> exit 0. --- tests/smoke_windows_powershell.ps1 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/smoke_windows_powershell.ps1 b/tests/smoke_windows_powershell.ps1 index d5ba426..0f0053e 100644 --- a/tests/smoke_windows_powershell.ps1 +++ b/tests/smoke_windows_powershell.ps1 @@ -51,8 +51,15 @@ $projectBob = Join-Path $testRoot 'project-bob' $projectMulti = Join-Path $testRoot 'project-multi' try { - New-Item -ItemType Directory -Force -Path $scriptsDir, (Join-Path $skillDir 'db'), (Join-Path $skillDir 'teams'), $storageDir, $projectSingle, $projectBob, $projectMulti | Out-Null + $typesDir = Join-Path $skillDir 'types' + New-Item -ItemType Directory -Force -Path $scriptsDir, $typesDir, (Join-Path $skillDir 'db'), (Join-Path $skillDir 'teams'), $storageDir, $projectSingle, $projectBob, $projectMulti | Out-Null Copy-Item -Recurse -Force -Path (Join-Path $RepoRoot 'scripts/*') -Destination $scriptsDir + # Ship the agent-type manifests too: join.sh and friends validate the agent + # type via the type registry, which resolves types//type.conf relative + # to the skill dir. Without this, `join … codex` fails with "Unknown agent + # type" (the real installer copies types/ via install.sh; this smoke stages + # the skill dir by hand, so it must mirror that). + Copy-Item -Recurse -Force -Path (Join-Path $RepoRoot 'types/*') -Destination $typesDir $wrapper = Join-Path $scriptsDir 'windows/agmsg.ps1' if (-not (Test-Path $wrapper)) { From aff73c2a4305a5655467b20c6970794dd6a57d0b Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 01:26:36 -0700 Subject: [PATCH 14/27] refactor(delivery): move enable/disable side effects into type plugs do_set no longer branches on codex/claude-code. The per-type onboarding (codex shim install + node preflight + restart guidance; claude-code's Monitor directive) and teardown (codex bridge stop; default watcher kill) are now agmsg_delivery_on_enable / agmsg_delivery_on_disable hooks in types//_delivery.sh, called generically by do_set. delivery.sh is now type-agnostic for the set/off paths. - types/codex/_delivery.sh: on_enable (shim+guidance), on_disable (stop bridge) - types/claude-code/_delivery.sh: on_enable (Monitor directive) - delivery.sh: default no-op on_enable / watcher-kill on_disable; do_set calls hooks test_delivery.bats 87/87 (behavior unchanged). The codex 'both'-mode guard stays in do_set (a small validation, not a side effect). --- scripts/delivery.sh | 96 ++++++++++------------------------ types/claude-code/_delivery.sh | 11 ++++ types/codex/_delivery.sh | 55 +++++++++++++++++++ 3 files changed, 94 insertions(+), 68 deletions(-) create mode 100644 types/claude-code/_delivery.sh create mode 100644 types/codex/_delivery.sh diff --git a/scripts/delivery.sh b/scripts/delivery.sh index c20ae12..65926e7 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -136,25 +136,35 @@ agmsg_delivery_apply_default() { mv "$tmp_state" "$hooks_file" } -# Apply delivery settings for a type (Template Method). A type may ship a -# delivery plug at types//_delivery.sh that defines agmsg_delivery_apply to -# override the default JSON event-hooks behavior; otherwise the default is used. -apply_settings() { - local type="$1" - local project="$2" - local mode="$3" - +# Default delivery entry points (Template Method). A type's plug +# (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; } + +# 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 "$type" 2>/dev/null || true)" + tdir="$(agmsg_type_dir "$1" 2>/dev/null || true)" if [ -n "$tdir" ] && [ -f "$tdir/_delivery.sh" ]; then # shellcheck disable=SC1090 . "$tdir/_delivery.sh" - agmsg_delivery_apply "$type" "$project" "$mode" - else - agmsg_delivery_apply_default "$type" "$project" "$mode" 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() { @@ -264,49 +274,9 @@ do_set() { case "$MODE" in monitor|both) - if [ "$TYPE" = "codex" ]; then - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/scripts/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/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 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." @@ -316,19 +286,9 @@ 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/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 + # Type-specific teardown via the plug (default: stop this project's + # watchers; codex stops its bridge instead). + agmsg_delivery_on_disable "$TYPE" "$PROJECT" emit_stop_directive ;; esac diff --git a/types/claude-code/_delivery.sh b/types/claude-code/_delivery.sh new file mode 100644 index 0000000..49d8bb0 --- /dev/null +++ b/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/types/codex/_delivery.sh b/types/codex/_delivery.sh new file mode 100644 index 0000000..f8a6f29 --- /dev/null +++ b/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/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/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/codex/codex-shim-install.sh remove" + echo " # then drop ~/.agents/bin from PATH if you added it for monitor" +} From 8072f08454c9aa52eaae4756517eae8937a8aee9 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 01:50:43 -0700 Subject: [PATCH 15/27] refactor(delivery): status as a Template Method plug do_status no longer branches on the rule-file type list. agmsg_delivery_status (default: derive mode from json-hooks + print entry detail) is overridden by rule-file types to report mode from the rule file's presence. delivery.sh is now type-agnostic for apply / on_enable / on_disable / status; only the codex 'both' guard names a type. - delivery.sh: agmsg_delivery_status_default + dispatch in do_status - delivery-rulefile.sh: rulefile_status (present=turn, absent=off) - gemini/antigravity/opencode/copilot _delivery.sh: agmsg_delivery_status delegation test_delivery.bats 87/87 (behavior unchanged). --- scripts/delivery.sh | 109 +++++++++++++++---------------- scripts/lib/delivery-rulefile.sh | 9 +++ types/antigravity/_delivery.sh | 1 + types/copilot/_delivery.sh | 1 + types/gemini/_delivery.sh | 1 + types/opencode/_delivery.sh | 1 + 6 files changed, 67 insertions(+), 55 deletions(-) diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 65926e7..81d1375 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -148,6 +148,56 @@ agmsg_delivery_on_enable() { :; } # 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() { @@ -302,62 +352,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/lib/delivery-rulefile.sh b/scripts/lib/delivery-rulefile.sh index 6484753..6e5fda2 100644 --- a/scripts/lib/delivery-rulefile.sh +++ b/scripts/lib/delivery-rulefile.sh @@ -36,3 +36,12 @@ EOF ;; 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/types/antigravity/_delivery.sh b/types/antigravity/_delivery.sh index 36937e3..a5d8460 100644 --- a/types/antigravity/_delivery.sh +++ b/types/antigravity/_delivery.sh @@ -2,3 +2,4 @@ # 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/types/copilot/_delivery.sh b/types/copilot/_delivery.sh index 473baff..9cad79b 100644 --- a/types/copilot/_delivery.sh +++ b/types/copilot/_delivery.sh @@ -52,3 +52,4 @@ agmsg_delivery_apply() { EOF fi } +agmsg_delivery_status() { rulefile_status "$@"; } diff --git a/types/gemini/_delivery.sh b/types/gemini/_delivery.sh index ed62514..6232dee 100644 --- a/types/gemini/_delivery.sh +++ b/types/gemini/_delivery.sh @@ -5,3 +5,4 @@ # 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/types/opencode/_delivery.sh b/types/opencode/_delivery.sh index 9750004..07fd4e7 100644 --- a/types/opencode/_delivery.sh +++ b/types/opencode/_delivery.sh @@ -34,3 +34,4 @@ After each tool call, automatically check the agmsg inbox for unread messages. EOF fi } +agmsg_delivery_status() { rulefile_status "$@"; } From ec5982ea62f18bc892ea6da70296b13b726d8081 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 01:51:05 -0700 Subject: [PATCH 16/27] refactor(codex): fold the codex runtime into types/codex/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-final shape: scripts/ holds the type-independent engine; types// holds everything for that type. Move the six codex runtime files (codex-bridge.js, codex-bridge-launcher.sh, codex-monitor.sh, codex-shim.sh, codex-shim-install.sh, watch-once.sh) from scripts/codex/ into types/codex/, where the manifest, template, and delivery/session-start plugs already live. Depth is unchanged (both are two levels under the skill root), so SKILL_DIR= ../.. and all codex-to-codex sibling refs are untouched. Only references that reach UP into the shared engine gain one level: $SCRIPT_DIR/../ -> $SCRIPT_DIR/../../scripts/ (lib/*, identities.sh, delivery.sh). codex-bridge.js computes SKILL_DIR/SCRIPTS_DIR absolutely, so it needs no path change (comments only). Callers follow the files: _delivery.sh / _session-start.sh, install.sh's chmod, the test harness (new TYPES var, $TYPES/codex paths), and the docs. Also fixes a latent bug the earlier scripts/codex move (#23f1b8f) left behind: codex-bridge-launcher.sh and codex-monitor.sh sourced $SCRIPT_DIR/lib/hash.sh (a path that never existed under scripts/codex/) — that move fixed node.sh's '../' but missed hash.sh, so both scripts would fail at the agmsg_sha1 call. Corrected to ../../scripts/lib/hash.sh alongside the other engine refs. Full bats suite green except a pre-existing, environment-flaky watch test (closed-consumer SIGPIPE timing) that fails identically on the unchanged base. --- README.md | 2 +- docs/codex-monitor-beta.md | 10 +++--- install.sh | 4 +-- tests/test_codex_bridge.bats | 32 +++++++++---------- tests/test_codex_shim.bats | 10 +++--- tests/test_helper.bash | 5 ++- tests/test_watch_once.bats | 12 +++---- types/codex/_delivery.sh | 6 ++-- types/codex/_session-start.sh | 2 +- .../codex/codex-bridge-launcher.sh | 10 +++--- {scripts => types}/codex/codex-bridge.js | 4 +-- {scripts => types}/codex/codex-monitor.sh | 6 ++-- .../codex/codex-shim-install.sh | 0 {scripts => types}/codex/codex-shim.sh | 2 +- types/codex/template.md | 4 +-- {scripts => types}/codex/watch-once.sh | 8 ++--- 16 files changed, 60 insertions(+), 57 deletions(-) rename {scripts => types}/codex/codex-bridge-launcher.sh (92%) rename {scripts => types}/codex/codex-bridge.js (99%) rename {scripts => types}/codex/codex-monitor.sh (96%) rename {scripts => types}/codex/codex-shim-install.sh (100%) rename {scripts => types}/codex/codex-shim.sh (97%) rename {scripts => types}/codex/watch-once.sh (93%) diff --git a/README.md b/README.md index f192d96..1349e84 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,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/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//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 diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 7460281..9acd4b6 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/codex-monitor.sh +~/.agents/skills/agmsg/types/codex/codex-monitor.sh ``` For custom command names, replace `agmsg` with the installed skill name: ```bash -~/.agents/skills//scripts/codex/codex-monitor.sh +~/.agents/skills//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/codex-bridge.js) -- [Monitor launcher](../scripts/codex/codex-monitor.sh) -- [Codex shim](../scripts/codex/codex-shim.sh) +- [Codex bridge implementation](../types/codex/codex-bridge.js) +- [Monitor launcher](../types/codex/codex-monitor.sh) +- [Codex shim](../types/codex/codex-shim.sh) diff --git a/install.sh b/install.sh index 62a1990..10c4d7d 100755 --- a/install.sh +++ b/install.sh @@ -282,7 +282,7 @@ if [ "$UPDATE_ONLY" = true ]; then 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/codex/"* 2>/dev/null || true + chmod +x "$SKILL_DIR/types/codex/"*.sh 2>/dev/null || true install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" @@ -331,7 +331,7 @@ cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" 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/codex/"* 2>/dev/null || true +chmod +x "$SKILL_DIR/types/codex/"*.sh 2>/dev/null || true install_windows_helpers # Marker file for uninstall detection diff --git a/tests/test_codex_bridge.bats b/tests/test_codex_bridge.bats index e269490..3bfc468 100644 --- a/tests/test_codex_bridge.bats +++ b/tests/test_codex_bridge.bats @@ -16,25 +16,25 @@ teardown() { } @test "codex-bridge: help exits successfully" { - run node "$SCRIPTS/codex/codex-bridge.js" --help + run node "$TYPES/codex/codex-bridge.js" --help [ "$status" -eq 0 ] [[ "$output" =~ "Beta Codex app-server bridge" ]] } @test "codex-bridge: resolve-only prints the selected identity" { - run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --resolve-only [ "$status" -eq 0 ] [ "$output" = $'team\talice' ] } @test "codex-bridge: resolve-only rejects ambiguous identities" { - run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --resolve-only + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --resolve-only [ "$status" -eq 1 ] [[ "$output" =~ "multiple identities match" ]] } @test "codex-bridge: rejects unsupported app-server endpoints" { - run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice --app-server http://127.0.0.1:9999 [ "$status" -eq 1 ] [[ "$output" =~ "supports only unix://PATH or ws://host:port" ]] } @@ -178,7 +178,7 @@ EOF sleep 0.1 done - run node "$SCRIPTS/codex/codex-bridge.js" \ + run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "unix://$sock" --timeout 1 --interval 1 --max-wakes 1 @@ -312,7 +312,7 @@ EOF local port port="$(cat "$portfile")" - run node "$SCRIPTS/codex/codex-bridge.js" \ + run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing \ --app-server "ws://127.0.0.1:$port" --timeout 1 --interval 1 --max-wakes 1 @@ -328,7 +328,7 @@ EOF mkdir -p "$TEST_SKILL_DIR/run" echo "$$" > "$TEST_SKILL_DIR/run/codex-bridge.team.alice.pid" - run node "$SCRIPTS/codex/codex-bridge.js" --project "$PROJ" --team team --name alice + run node "$TYPES/codex/codex-bridge.js" --project "$PROJ" --team team --name alice [ "$status" -eq 1 ] [[ "$output" =~ "bridge already running" ]] } @@ -391,7 +391,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 [ "$status" -eq 0 ] @@ -436,7 +436,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-existing --timeout 20 [ "$status" -eq 0 ] @@ -484,7 +484,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 5000 --timeout 20 [ "$status" -eq 0 ] @@ -518,7 +518,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread loaded --loaded-timeout 1500 --timeout 20 [ "$status" -ne 0 ] @@ -585,7 +585,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --max-wakes 1 --inline-inbox [ "$status" -eq 0 ] @@ -648,7 +648,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 [ "$status" -eq 1 ] @@ -695,7 +695,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 1 --max-wakes 2 [ "$status" -eq 0 ] @@ -741,7 +741,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] @@ -801,7 +801,7 @@ rl.on("line", (line) => { }); EOF - AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$SCRIPTS/codex/codex-bridge.js" \ + AGMSG_CODEX_APP_SERVER_CMD="node $fake $log" run node "$TYPES/codex/codex-bridge.js" \ --project "$PROJ" --team team --name alice --thread thread-active --timeout 1 --interval 1 --turn-timeout 30 --max-wakes 2 [ "$status" -eq 0 ] # not exit 1 from the stale-wake guard diff --git a/tests/test_codex_shim.bats b/tests/test_codex_shim.bats index 2cbd450..ec291c6 100644 --- a/tests/test_codex_shim.bats +++ b/tests/test_codex_shim.bats @@ -38,7 +38,7 @@ teardown() { @test "codex shim: monitor project routes resume through codex-monitor" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex/codex-shim.sh" resume --last' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" resume --last' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--last>" "$CALL_LOG" @@ -47,7 +47,7 @@ teardown() { @test "codex shim: monitor project routes prompt launches through top-level codex" { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null - run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$SCRIPTS/codex/codex-shim.sh" "fix this"' + run bash -c 'cd "$TEST_PROJECT" && AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" bash "$TYPES/codex/codex-shim.sh" "fix this"' [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> " "$CALL_LOG" @@ -57,7 +57,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set turn codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex/codex-shim.sh" resume --last + run bash "$TYPES/codex/codex-shim.sh" resume --last [ "$status" -eq 0 ] grep -q "real-codex <--last>" "$CALL_LOG" @@ -68,7 +68,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex/codex-shim.sh" exec echo hi + run bash "$TYPES/codex/codex-shim.sh" exec echo hi [ "$status" -eq 0 ] grep -q "real-codex " "$CALL_LOG" @@ -79,7 +79,7 @@ teardown() { bash "$SCRIPTS/delivery.sh" set monitor codex "$TEST_PROJECT" >/dev/null AGMSG_REAL_CODEX="$FAKE_CODEX" AGMSG_CODEX_MONITOR_CMD="$FAKE_MONITOR" \ - run bash "$SCRIPTS/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume + run bash "$TYPES/codex/codex-shim.sh" --cd "$TEST_PROJECT" resume [ "$status" -eq 0 ] grep -q "monitor real=$FAKE_CODEX <--project> <$TEST_PROJECT> <--codex-command> <--> <--cd> <$TEST_PROJECT>" "$CALL_LOG" diff --git a/tests/test_helper.bash b/tests/test_helper.bash index e5c45cc..1419286 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -10,18 +10,21 @@ setup_test_env() { cp -R "$BATS_TEST_DIRNAME"/../scripts/. "$TEST_SKILL_DIR/scripts/" chmod +x "$TEST_SKILL_DIR/scripts/"*.sh chmod +x "$TEST_SKILL_DIR/scripts/"*.js 2>/dev/null || true - chmod +x "$TEST_SKILL_DIR/scripts/codex/"* 2>/dev/null || true # Copy the agent-type manifests so the type registry resolves types inside the # sandbox (scripts/lib/type-registry.sh reads /types//type.conf). + # types// also holds each type's runtime now — codex's launcher/bridge/ + # shim/watch-once were folded out of scripts/codex/ into types/codex/. mkdir -p "$TEST_SKILL_DIR/types" cp -R "$BATS_TEST_DIRNAME"/../types/. "$TEST_SKILL_DIR/types/" + chmod +x "$TEST_SKILL_DIR/types/codex/"*.sh 2>/dev/null || true # Initialize DB bash "$TEST_SKILL_DIR/scripts/internal/init-db.sh" # Convenience vars export SCRIPTS="$TEST_SKILL_DIR/scripts" + export TYPES="$TEST_SKILL_DIR/types" # Sandbox HOME so NO test can touch the developer's real home. Several paths # write under $HOME — e.g. codex-shim-install.sh creates $HOME/.agents/bin/codex diff --git a/tests/test_watch_once.bats b/tests/test_watch_once.bats index c9fb239..4bb5903 100644 --- a/tests/test_watch_once.bats +++ b/tests/test_watch_once.bats @@ -14,7 +14,7 @@ teardown() { } @test "watch-once: exits 2 on timeout when no unread inbound exists" { - run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -22,7 +22,7 @@ teardown() { @test "watch-once: reports existing unread inbound without marking it read" { bash "$SCRIPTS/send.sh" team bob alice "hello pending" >/dev/null - run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 0 ] [[ "$output" =~ "status=pending" ]] [[ "$output" =~ "count=1" ]] @@ -36,7 +36,7 @@ teardown() { bash "$SCRIPTS/send.sh" team bob alice "read already" >/dev/null bash "$SCRIPTS/inbox.sh" team alice >/dev/null - run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @@ -44,13 +44,13 @@ teardown() { @test "watch-once: ignores messages addressed to another agent" { bash "$SCRIPTS/send.sh" team alice bob "for bob" >/dev/null - run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 2 ] [[ "$output" =~ "status=timeout" ]] } @test "watch-once: detects a message that arrives after it starts" { - bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ + bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 5 --interval 1 \ >"$TEST_SKILL_DIR/watch-once.out" 2>"$TEST_SKILL_DIR/watch-once.err" & local pid=$! sleep 1 @@ -67,7 +67,7 @@ teardown() { bash "$SCRIPTS/actas-claim.sh" "$PROJ" codex alice other-sid >/dev/null bash "$SCRIPTS/send.sh" team bob alice "locked out" >/dev/null - run bash "$SCRIPTS/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 + run bash "$TYPES/codex/watch-once.sh" "$PROJ" codex --name alice --team team --timeout 1 --interval 1 [ "$status" -eq 1 ] [[ "$output" =~ "no available subscription" ]] } diff --git a/types/codex/_delivery.sh b/types/codex/_delivery.sh index f8a6f29..ce7ce99 100644 --- a/types/codex/_delivery.sh +++ b/types/codex/_delivery.sh @@ -8,7 +8,7 @@ # Args (both hooks): on_enable ; on_disable . agmsg_delivery_on_enable() { - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/scripts/codex/codex-shim-install.sh" install; then + if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/types/codex/codex-shim-install.sh" install; then echo "Codex monitor shim installed at ~/.agents/bin/codex." case ":$PATH:" in *":$HOME/.agents/bin:"*) @@ -24,7 +24,7 @@ agmsg_delivery_on_enable() { 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/codex-monitor.sh, or resolve the shim install issue above." + echo "Future Codex sessions: launch with $SKILL_DIR/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 @@ -50,6 +50,6 @@ agmsg_delivery_on_disable() { 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/codex-shim-install.sh remove" + echo " $SKILL_DIR/types/codex/codex-shim-install.sh remove" echo " # then drop ~/.agents/bin from PATH if you added it for monitor" } diff --git a/types/codex/_session-start.sh b/types/codex/_session-start.sh index 236e54d..3c15cf4 100644 --- a/types/codex/_session-start.sh +++ b/types/codex/_session-start.sh @@ -115,7 +115,7 @@ agmsg_session_start() { if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") else - bridge_run=("$(agmsg_resolve_node)" "$SCRIPT_DIR/codex/codex-bridge.js") + bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/types/codex/codex-bridge.js") fi nohup "${bridge_run[@]}" \ --project "$PROJECT" \ diff --git a/scripts/codex/codex-bridge-launcher.sh b/types/codex/codex-bridge-launcher.sh similarity index 92% rename from scripts/codex/codex-bridge-launcher.sh rename to types/codex/codex-bridge-launcher.sh index 3b2c224..9a2c691 100755 --- a/scripts/codex/codex-bridge-launcher.sh +++ b/types/codex/codex-bridge-launcher.sh @@ -18,20 +18,20 @@ PARENT_PID="${4:?Missing parent_pid}" SCRIPT_DIR="$(cd "$(dirname "$0")" && 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=../../scripts/lib/hash.sh +source "$SCRIPT_DIR/../../scripts/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=../../scripts/lib/node.sh +source "$SCRIPT_DIR/../../scripts/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/../../scripts/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null \ | awk -v t="$TAB" 'NF >= 2 { print $1 t $2 }' \ | sort -u } diff --git a/scripts/codex/codex-bridge.js b/types/codex/codex-bridge.js similarity index 99% rename from scripts/codex/codex-bridge.js rename to types/codex/codex-bridge.js index 524f2a3..6f2681e 100755 --- a/scripts/codex/codex-bridge.js +++ b/types/codex/codex-bridge.js @@ -8,9 +8,9 @@ const net = require("net"); const path = require("path"); const readline = require("readline"); -const SCRIPT_DIR = __dirname; // .../scripts/codex +const SCRIPT_DIR = __dirname; // .../types/codex (codex siblings live here) const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", ".."); // skill root -const SCRIPTS_DIR = path.join(SKILL_DIR, "scripts"); // top-level scripts (siblings live here) +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 diff --git a/scripts/codex/codex-monitor.sh b/types/codex/codex-monitor.sh similarity index 96% rename from scripts/codex/codex-monitor.sh rename to types/codex/codex-monitor.sh index ebd79a3..1a0affa 100755 --- a/scripts/codex/codex-monitor.sh +++ b/types/codex/codex-monitor.sh @@ -10,8 +10,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && 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=../../scripts/lib/hash.sh +source "$SCRIPT_DIR/../../scripts/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/../../scripts/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/codex-shim-install.sh b/types/codex/codex-shim-install.sh similarity index 100% rename from scripts/codex/codex-shim-install.sh rename to types/codex/codex-shim-install.sh diff --git a/scripts/codex/codex-shim.sh b/types/codex/codex-shim.sh similarity index 97% rename from scripts/codex/codex-shim.sh rename to types/codex/codex-shim.sh index 672e380..c36413f 100755 --- a/scripts/codex/codex-shim.sh +++ b/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/../../scripts/delivery.sh" status codex "$project" 2>/dev/null || true)" printf '%s\n' "$status" | grep -qx "mode: monitor" } diff --git a/types/codex/template.md b/types/codex/template.md index 1a276a2..c2698b3 100644 --- a/types/codex/template.md +++ b/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/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__/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/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__/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/codex/watch-once.sh b/types/codex/watch-once.sh similarity index 93% rename from scripts/codex/watch-once.sh rename to types/codex/watch-once.sh index b2e2496..754477d 100755 --- a/scripts/codex/watch-once.sh +++ b/types/codex/watch-once.sh @@ -47,13 +47,13 @@ case "$INTERVAL" in ''|*[!0-9]*) echo "watch-once: --interval must be a whole nu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" -source "$SCRIPT_DIR/../lib/storage.sh" +source "$SCRIPT_DIR/../../scripts/lib/storage.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../lib/actas-lock.sh" +source "$SCRIPT_DIR/../../scripts/lib/actas-lock.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../lib/resolve-project.sh" +source "$SCRIPT_DIR/../../scripts/lib/resolve-project.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../lib/subscription.sh" +source "$SCRIPT_DIR/../../scripts/lib/subscription.sh" PROJECT_PATH="$(agmsg_resolve_project "$PROJECT_PATH" "$AGENT_TYPE")" DB="$(agmsg_db_path)" From bb3add4a0312a1678cf3f90887d78d0b3cd2e5f8 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 08:09:31 -0700 Subject: [PATCH 17/27] refactor(delivery): data-drive Windows hook wrapping via manifest The commandWindows wrapping in hooks-json.sh keyed off a hardcoded "codex" type name, violating that layer's stated type-agnostic boundary. Resolve the wrap decision in delivery.sh (the layer that knows agent types) from a new hook_windows_wrap=yes manifest key and pass a plain flag down. hooks-json.sh now references no type name. --- scripts/delivery.sh | 19 +++++++++++++------ scripts/lib/hooks-json.sh | 13 +++++++------ types/codex/type.conf | 1 + 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 81d1375..c7f008c 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -86,6 +86,13 @@ agmsg_delivery_apply_default() { 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 @@ -106,20 +113,20 @@ agmsg_delivery_apply_default() { 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 diff --git a/scripts/lib/hooks-json.sh b/scripts/lib/hooks-json.sh index 80d7a13..97d3802 100644 --- a/scripts/lib/hooks-json.sh +++ b/scripts/lib/hooks-json.sh @@ -94,15 +94,16 @@ windows_wrap() { # 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). +# 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 hook_type="${4:-}" + local windows_wrap="${4:-}" local sql_path sql_path=$(sql_readfile_path "$path") @@ -115,7 +116,7 @@ add_event_entry_file() { 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 + if [ "$windows_wrap" = "yes" ]; then local cw cw_lit cw=$(windows_wrap "$cmd") cw_lit=$(printf '%s' "$cw" | sed "s/'/''/g") diff --git a/types/codex/type.conf b/types/codex/type.conf index 9633b35..079be22 100644 --- a/types/codex/type.conf +++ b/types/codex/type.conf @@ -8,3 +8,4 @@ detect_proc=codex codex-* hooks_file=.codex/hooks.json monitor=no stop_output=json +hook_windows_wrap=yes From de510ebe633397dc6d27549ce0020627f3d1db11 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 13:02:36 -0700 Subject: [PATCH 18/27] refactor(delivery): consolidate mode support into delivery_modes manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode acceptance was scattered: a hardcoded codex 'both' guard in delivery.sh plus per-type monitor/both rejection branches duplicated in the opencode and copilot plugs. Declare each type's accepted modes once via the delivery_modes= manifest key and gate them centrally in do_set — a typo still gets the generic "Unknown mode" error; an unsupported real mode gets "not supported for ". The codex 'both' guard and the opencode/copilot rejection arms are now redundant and removed (rejection happens before any file is touched, preserving the don't-wipe-turn guarantee). The Windows dispatcher keeps an explicit codex monitor/both guard: the codex bridge is unsupported on Windows (#182, tests skip_on_windows), and delivery_modes is a global fact that can't express a platform exception. --- scripts/delivery.sh | 22 ++++++++++++++++++---- scripts/windows/dispatch.sh | 8 +++++++- tests/test_delivery.bats | 6 ++++-- types/antigravity/type.conf | 1 + types/claude-code/type.conf | 1 + types/codex/type.conf | 1 + types/copilot/_delivery.sh | 22 +++++----------------- types/copilot/type.conf | 1 + types/gemini/type.conf | 1 + types/opencode/_delivery.sh | 17 +++-------------- types/opencode/type.conf | 1 + 11 files changed, 43 insertions(+), 38 deletions(-) diff --git a/scripts/delivery.sh b/scripts/delivery.sh index c7f008c..3863b81 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -317,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" diff --git a/scripts/windows/dispatch.sh b/scripts/windows/dispatch.sh index a256d6d..92374b7 100755 --- a/scripts/windows/dispatch.sh +++ b/scripts/windows/dispatch.sh @@ -211,8 +211,14 @@ case "$COMMAND" in run_script delivery.sh status "$AGENT_TYPE" "$PROJECT" ;; 1) + # Platform-specific guard (not incidental type coupling): the Codex + # monitor bridge is unsupported on Windows (its tests skip_on_windows, + # #182), so codex monitor/both can't work here even though the codex + # manifest lists 'monitor' for POSIX. delivery_modes is a global fact and + # can't express "monitor everywhere except Windows", so this one Windows + # dispatcher keeps an explicit early reject with a clear message. if [ "$AGENT_TYPE" = "codex" ] && { [ "$1" = "monitor" ] || [ "$1" = "both" ]; }; then - echo "Codex has no Monitor tool; only 'turn' or 'off' modes are supported." >&2 + echo "Codex has no Monitor tool on Windows; only 'turn' or 'off' modes are supported." >&2 exit 2 fi run_script delivery.sh set "$1" "$AGENT_TYPE" "$PROJECT" diff --git a/tests/test_delivery.bats b/tests/test_delivery.bats index 9a2d3a1..68f2327 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1242,10 +1242,12 @@ EOF grep -q "session-start.sh" "$hook_file" } -@test "delivery set both (codex): rejected for bridge beta" { +@test "delivery set both (codex): rejected by the delivery_modes gate" { + # codex's manifest omits 'both' (delivery_modes=monitor turn off), so the + # central gate in delivery.sh rejects it before any file is touched. run bash "$SCRIPTS/delivery.sh" set both codex "$TEST_PROJECT" [ "$status" -eq 1 ] - [[ "$output" == *"not supported for codex bridge beta"* ]] + [[ "$output" == *"not supported for codex"* ]] } diff --git a/types/antigravity/type.conf b/types/antigravity/type.conf index f2f4593..01f4f5c 100644 --- a/types/antigravity/type.conf +++ b/types/antigravity/type.conf @@ -4,3 +4,4 @@ template=template.md detect=explicit hooks_file=.agent/rules/agmsg.md monitor=no +delivery_modes=monitor turn both off diff --git a/types/claude-code/type.conf b/types/claude-code/type.conf index 29581bc..616a992 100644 --- a/types/claude-code/type.conf +++ b/types/claude-code/type.conf @@ -7,3 +7,4 @@ 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/types/codex/type.conf b/types/codex/type.conf index 079be22..00225fd 100644 --- a/types/codex/type.conf +++ b/types/codex/type.conf @@ -9,3 +9,4 @@ hooks_file=.codex/hooks.json monitor=no stop_output=json hook_windows_wrap=yes +delivery_modes=monitor turn off diff --git a/types/copilot/_delivery.sh b/types/copilot/_delivery.sh index 9cad79b..b434df9 100644 --- a/types/copilot/_delivery.sh +++ b/types/copilot/_delivery.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash -# copilot delivery plug — JSON hooks file (.github/hooks/agmsg.json), turn|off -# only (no Monitor-tool equivalent). Uses resolve_hooks_file + SKILL_DIR from -# delivery.sh's sourced context. +# 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" @@ -9,20 +11,6 @@ agmsg_delivery_apply() { 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" diff --git a/types/copilot/type.conf b/types/copilot/type.conf index f9a82b1..361b89a 100644 --- a/types/copilot/type.conf +++ b/types/copilot/type.conf @@ -5,3 +5,4 @@ detect=explicit hooks_file=.github/hooks/agmsg.json monitor=no stop_output=json +delivery_modes=turn off diff --git a/types/gemini/type.conf b/types/gemini/type.conf index 5a88b91..795f078 100644 --- a/types/gemini/type.conf +++ b/types/gemini/type.conf @@ -5,3 +5,4 @@ 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/types/opencode/_delivery.sh b/types/opencode/_delivery.sh index 07fd4e7..6c071ed 100644 --- a/types/opencode/_delivery.sh +++ b/types/opencode/_delivery.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# opencode delivery plug — markdown rule-file, but turn|off only (no Monitor-tool -# equivalent, so monitor/both are rejected). Uses resolve_hooks_file + SKILL_DIR +# 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" @@ -9,18 +10,6 @@ agmsg_delivery_apply() { 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 - ;; - esac - rm -f "$rule_file" if [ "$mode" = "turn" ]; then diff --git a/types/opencode/type.conf b/types/opencode/type.conf index 9e998b3..e3c8359 100644 --- a/types/opencode/type.conf +++ b/types/opencode/type.conf @@ -4,3 +4,4 @@ template=template.md detect_proc=opencode opencode-* hooks_file=.opencode/rules/agmsg.md monitor=no +delivery_modes=turn off From b8b3da7aaa15f0189f67eb1a9698d6b8e7e6499e Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 13:10:59 -0700 Subject: [PATCH 19/27] docs(readme): lead Quick Start with npx, the zero-clone install path The Quick Start opened with the curl one-liner and git clone; npx agmsg is the lowest-friction way to get the skill installed (it runs the same setup.sh bootstrapper, no clone). Lead with it and point readers who want to inspect the code, track main, or pick a custom command name to the Install section. --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1349e84..3e7adf0 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,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 +51,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 From 33d40693ef50eb519defa3dc5333b839d7eb78eb Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 14:09:12 -0700 Subject: [PATCH 20/27] refactor(types): relocate types/ under scripts/drivers/types/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the driver-interface spec (docs/spec/driver-interface.md §1.1, bundled drivers live at scripts/drivers//), move the agent-type tree from the repo root into scripts/drivers/types/. Agent types contain executable runtime (_delivery.sh, codex's bridge/launcher/shim), so they belong under scripts/, not at the top level. - git mv types/ -> scripts/drivers/types/ - type-registry in-tree search dir: /types -> /scripts/drivers/types - codex runtime upward refs reshift for the +2 depth: $SCRIPT_DIR/../.. (SKILL_DIR) -> ../../../.., and ../../scripts/ -> ../../../; codex-bridge.js resolves SKILL_DIR four levels up - install.sh: the single recursive scripts/ copy now carries the type tree, so the separate types/ copy is dropped; codex chmod path follows - tests: test_helper TYPES var + codex chmod; the scripts/ copy already brings the tree, so the separate types/ stage is dropped (bats + powershell smoke) - docs/comments/user-facing strings updated to the new path Discovery of EXTERNAL add-on dirs is unchanged here (still the legacy ~/.config/agmsg path); the plugins/AGMSG_PLUGIN_DIRS redesign + opt-in gating follow in the next commit. --- docs/codex-monitor-beta.md | 10 ++++---- install.sh | 23 ++++++++----------- scripts/delivery.sh | 4 ++-- .../drivers/types}/antigravity/_delivery.sh | 0 .../drivers/types}/antigravity/template.md | 0 .../drivers/types}/antigravity/type.conf | 0 .../drivers/types}/claude-code/_delivery.sh | 0 .../drivers/types}/claude-code/template.md | 0 .../drivers/types}/claude-code/type.conf | 0 .../drivers/types}/codex/_delivery.sh | 6 ++--- .../drivers/types}/codex/_session-start.sh | 2 +- .../types}/codex/codex-bridge-launcher.sh | 12 +++++----- .../drivers/types}/codex/codex-bridge.js | 4 ++-- .../drivers/types}/codex/codex-monitor.sh | 8 +++---- .../types}/codex/codex-shim-install.sh | 0 .../drivers/types}/codex/codex-shim.sh | 2 +- .../drivers/types}/codex/template.md | 4 ++-- .../drivers/types}/codex/type.conf | 0 .../drivers/types}/codex/watch-once.sh | 10 ++++---- .../drivers/types}/copilot/_delivery.sh | 0 .../drivers/types}/copilot/template.md | 0 .../drivers/types}/copilot/type.conf | 0 .../drivers/types}/gemini/_delivery.sh | 0 .../drivers/types}/gemini/template.md | 0 .../drivers/types}/gemini/type.conf | 0 .../drivers/types}/opencode/_delivery.sh | 0 .../drivers/types}/opencode/template.md | 0 .../drivers/types}/opencode/type.conf | 0 scripts/join.sh | 4 ++-- scripts/lib/delivery-rulefile.sh | 2 +- scripts/lib/type-registry.sh | 8 +++---- scripts/session-start.sh | 2 +- scripts/spawn.sh | 2 +- tests/smoke_windows_powershell.ps1 | 13 ++++------- tests/test_helper.bash | 14 +++++------ tests/test_type_registry.bats | 10 ++++---- 36 files changed, 66 insertions(+), 74 deletions(-) rename {types => scripts/drivers/types}/antigravity/_delivery.sh (100%) rename {types => scripts/drivers/types}/antigravity/template.md (100%) rename {types => scripts/drivers/types}/antigravity/type.conf (100%) rename {types => scripts/drivers/types}/claude-code/_delivery.sh (100%) rename {types => scripts/drivers/types}/claude-code/template.md (100%) rename {types => scripts/drivers/types}/claude-code/type.conf (100%) rename {types => scripts/drivers/types}/codex/_delivery.sh (88%) rename {types => scripts/drivers/types}/codex/_session-start.sh (98%) rename {types => scripts/drivers/types}/codex/codex-bridge-launcher.sh (91%) rename {types => scripts/drivers/types}/codex/codex-bridge.js (99%) rename {types => scripts/drivers/types}/codex/codex-monitor.sh (95%) rename {types => scripts/drivers/types}/codex/codex-shim-install.sh (100%) rename {types => scripts/drivers/types}/codex/codex-shim.sh (97%) rename {types => scripts/drivers/types}/codex/template.md (91%) rename {types => scripts/drivers/types}/codex/type.conf (100%) rename {types => scripts/drivers/types}/codex/watch-once.sh (92%) rename {types => scripts/drivers/types}/copilot/_delivery.sh (100%) rename {types => scripts/drivers/types}/copilot/template.md (100%) rename {types => scripts/drivers/types}/copilot/type.conf (100%) rename {types => scripts/drivers/types}/gemini/_delivery.sh (100%) rename {types => scripts/drivers/types}/gemini/template.md (100%) rename {types => scripts/drivers/types}/gemini/type.conf (100%) rename {types => scripts/drivers/types}/opencode/_delivery.sh (100%) rename {types => scripts/drivers/types}/opencode/template.md (100%) rename {types => scripts/drivers/types}/opencode/type.conf (100%) diff --git a/docs/codex-monitor-beta.md b/docs/codex-monitor-beta.md index 9acd4b6..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/types/codex/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//types/codex/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](../types/codex/codex-bridge.js) -- [Monitor launcher](../types/codex/codex-monitor.sh) -- [Codex shim](../types/codex/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/install.sh b/install.sh index 10c4d7d..fcc97e9 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" AGENTS_DIR="$HOME/.agents" # Type registry — resolve each type's SKILL command template from its manifest -# (types//template.md) instead of a hardcoded templates/ path. Read-only +# (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" @@ -253,12 +253,10 @@ if [ "$UPDATE_ONLY" = true ]; then gemini|antigravity|opencode) 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/) ship without enumerating files. + # 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/" - # Ship the agent-type manifests (and their co-located SKILL templates) so the - # type registry resolves types post-install. - mkdir -p "$SKILL_DIR/types" - cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" # 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 @@ -282,7 +280,7 @@ if [ "$UPDATE_ONLY" = true ]; then 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/types/codex/"*.sh 2>/dev/null || true + chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true install_windows_helpers INSTALLED_VERSION="$(agmsg_source_version)" printf '%s\n' "$INSTALLED_VERSION" > "$SKILL_DIR/VERSION" @@ -316,22 +314,21 @@ echo " Installing to ~/.agents/skills/$CMD_NAME/ ..." mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} # SKILL.md is generated from the agent-specific command template, resolved from -# the type manifest (types//template.md). The shared SKILL.md uses the +# 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) 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/) ship without enumerating files. +# 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/" -# Ship the agent-type manifests (and their co-located SKILL templates) so the -# type registry resolves types post-install. -cp -R "$SCRIPT_DIR/types/." "$SKILL_DIR/types/" cp "$SCRIPT_DIR/openai.yaml" "$SKILL_DIR/agents/openai.yaml" 2>/dev/null || true chmod +x "$SKILL_DIR/scripts/"*.sh -chmod +x "$SKILL_DIR/types/codex/"*.sh 2>/dev/null || true +chmod +x "$SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true install_windows_helpers # Marker file for uninstall detection diff --git a/scripts/delivery.sh b/scripts/delivery.sh index 3863b81..d1efed8 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -76,7 +76,7 @@ resolve_hooks_file() { # 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 types//_delivery.sh. +# types override this by defining agmsg_delivery_apply in scripts/drivers/types//_delivery.sh. agmsg_delivery_apply_default() { local type="$1" local project="$2" @@ -144,7 +144,7 @@ agmsg_delivery_apply_default() { } # Default delivery entry points (Template Method). A type's plug -# (types//_delivery.sh) may override any subset of these: +# (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) diff --git a/types/antigravity/_delivery.sh b/scripts/drivers/types/antigravity/_delivery.sh similarity index 100% rename from types/antigravity/_delivery.sh rename to scripts/drivers/types/antigravity/_delivery.sh diff --git a/types/antigravity/template.md b/scripts/drivers/types/antigravity/template.md similarity index 100% rename from types/antigravity/template.md rename to scripts/drivers/types/antigravity/template.md diff --git a/types/antigravity/type.conf b/scripts/drivers/types/antigravity/type.conf similarity index 100% rename from types/antigravity/type.conf rename to scripts/drivers/types/antigravity/type.conf diff --git a/types/claude-code/_delivery.sh b/scripts/drivers/types/claude-code/_delivery.sh similarity index 100% rename from types/claude-code/_delivery.sh rename to scripts/drivers/types/claude-code/_delivery.sh diff --git a/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md similarity index 100% rename from types/claude-code/template.md rename to scripts/drivers/types/claude-code/template.md diff --git a/types/claude-code/type.conf b/scripts/drivers/types/claude-code/type.conf similarity index 100% rename from types/claude-code/type.conf rename to scripts/drivers/types/claude-code/type.conf diff --git a/types/codex/_delivery.sh b/scripts/drivers/types/codex/_delivery.sh similarity index 88% rename from types/codex/_delivery.sh rename to scripts/drivers/types/codex/_delivery.sh index ce7ce99..32918ed 100644 --- a/types/codex/_delivery.sh +++ b/scripts/drivers/types/codex/_delivery.sh @@ -8,7 +8,7 @@ # Args (both hooks): on_enable ; on_disable . agmsg_delivery_on_enable() { - if AGMSG_CODEX_SHIM_INSTALL_QUIET=1 "$SKILL_DIR/types/codex/codex-shim-install.sh" install; then + 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:"*) @@ -24,7 +24,7 @@ agmsg_delivery_on_enable() { esac else echo "Codex monitor mode is enabled, but the codex shim was not installed." - echo "Future Codex sessions: launch with $SKILL_DIR/types/codex/codex-monitor.sh, or resolve the shim install issue above." + 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 @@ -50,6 +50,6 @@ agmsg_delivery_on_disable() { 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/types/codex/codex-shim-install.sh remove" + 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/types/codex/_session-start.sh b/scripts/drivers/types/codex/_session-start.sh similarity index 98% rename from types/codex/_session-start.sh rename to scripts/drivers/types/codex/_session-start.sh index 3c15cf4..d3d2529 100644 --- a/types/codex/_session-start.sh +++ b/scripts/drivers/types/codex/_session-start.sh @@ -115,7 +115,7 @@ agmsg_session_start() { if [ -n "${AGMSG_CODEX_BRIDGE_CMD:-}" ]; then bridge_run=("$AGMSG_CODEX_BRIDGE_CMD") else - bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/types/codex/codex-bridge.js") + bridge_run=("$(agmsg_resolve_node)" "$SKILL_DIR/scripts/drivers/types/codex/codex-bridge.js") fi nohup "${bridge_run[@]}" \ --project "$PROJECT" \ diff --git a/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh similarity index 91% rename from types/codex/codex-bridge-launcher.sh rename to scripts/drivers/types/codex/codex-bridge-launcher.sh index 9a2c691..d103b7a 100755 --- a/types/codex/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=../../scripts/lib/hash.sh -source "$SCRIPT_DIR/../../scripts/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=../../scripts/lib/node.sh -source "$SCRIPT_DIR/../../scripts/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/../../scripts/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/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js similarity index 99% rename from types/codex/codex-bridge.js rename to scripts/drivers/types/codex/codex-bridge.js index 6f2681e..91130e4 100755 --- a/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -8,8 +8,8 @@ const net = require("net"); const path = require("path"); const readline = require("readline"); -const SCRIPT_DIR = __dirname; // .../types/codex (codex siblings live here) -const SKILL_DIR = path.resolve(SCRIPT_DIR, "..", ".."); // skill root +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"); diff --git a/types/codex/codex-monitor.sh b/scripts/drivers/types/codex/codex-monitor.sh similarity index 95% rename from types/codex/codex-monitor.sh rename to scripts/drivers/types/codex/codex-monitor.sh index 1a0affa..db671d8 100755 --- a/types/codex/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=../../scripts/lib/hash.sh -source "$SCRIPT_DIR/../../scripts/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/../../scripts/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/types/codex/codex-shim-install.sh b/scripts/drivers/types/codex/codex-shim-install.sh similarity index 100% rename from types/codex/codex-shim-install.sh rename to scripts/drivers/types/codex/codex-shim-install.sh diff --git a/types/codex/codex-shim.sh b/scripts/drivers/types/codex/codex-shim.sh similarity index 97% rename from types/codex/codex-shim.sh rename to scripts/drivers/types/codex/codex-shim.sh index c36413f..ed2bd77 100755 --- a/types/codex/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/../../scripts/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/types/codex/template.md b/scripts/drivers/types/codex/template.md similarity index 91% rename from types/codex/template.md rename to scripts/drivers/types/codex/template.md index c2698b3..bd0f1c7 100644 --- a/types/codex/template.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__/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 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__/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" +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/types/codex/type.conf b/scripts/drivers/types/codex/type.conf similarity index 100% rename from types/codex/type.conf rename to scripts/drivers/types/codex/type.conf diff --git a/types/codex/watch-once.sh b/scripts/drivers/types/codex/watch-once.sh similarity index 92% rename from types/codex/watch-once.sh rename to scripts/drivers/types/codex/watch-once.sh index 754477d..c3737b9 100755 --- a/types/codex/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/../../scripts/lib/storage.sh" +SKILL_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +source "$SCRIPT_DIR/../../../lib/storage.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../../scripts/lib/actas-lock.sh" +source "$SCRIPT_DIR/../../../lib/actas-lock.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../../scripts/lib/resolve-project.sh" +source "$SCRIPT_DIR/../../../lib/resolve-project.sh" # shellcheck disable=SC1091 -source "$SCRIPT_DIR/../../scripts/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/types/copilot/_delivery.sh b/scripts/drivers/types/copilot/_delivery.sh similarity index 100% rename from types/copilot/_delivery.sh rename to scripts/drivers/types/copilot/_delivery.sh diff --git a/types/copilot/template.md b/scripts/drivers/types/copilot/template.md similarity index 100% rename from types/copilot/template.md rename to scripts/drivers/types/copilot/template.md diff --git a/types/copilot/type.conf b/scripts/drivers/types/copilot/type.conf similarity index 100% rename from types/copilot/type.conf rename to scripts/drivers/types/copilot/type.conf diff --git a/types/gemini/_delivery.sh b/scripts/drivers/types/gemini/_delivery.sh similarity index 100% rename from types/gemini/_delivery.sh rename to scripts/drivers/types/gemini/_delivery.sh diff --git a/types/gemini/template.md b/scripts/drivers/types/gemini/template.md similarity index 100% rename from types/gemini/template.md rename to scripts/drivers/types/gemini/template.md diff --git a/types/gemini/type.conf b/scripts/drivers/types/gemini/type.conf similarity index 100% rename from types/gemini/type.conf rename to scripts/drivers/types/gemini/type.conf diff --git a/types/opencode/_delivery.sh b/scripts/drivers/types/opencode/_delivery.sh similarity index 100% rename from types/opencode/_delivery.sh rename to scripts/drivers/types/opencode/_delivery.sh diff --git a/types/opencode/template.md b/scripts/drivers/types/opencode/template.md similarity index 100% rename from types/opencode/template.md rename to scripts/drivers/types/opencode/template.md diff --git a/types/opencode/type.conf b/scripts/drivers/types/opencode/type.conf similarity index 100% rename from types/opencode/type.conf rename to scripts/drivers/types/opencode/type.conf diff --git a/scripts/join.sh b/scripts/join.sh index 928b7cc..8dfa65e 100755 --- a/scripts/join.sh +++ b/scripts/join.sh @@ -7,7 +7,7 @@ set -euo pipefail TEAM="${1:?Usage: join.sh }" AGENT_ID="${2:?Missing agent_id}" -AGENT_TYPE="${3:?Missing type (a registered type under types//)}" +AGENT_TYPE="${3:?Missing type (a registered type under scripts/drivers/types//)}" PROJECT_PATH="${4:?Missing project_path}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -16,7 +16,7 @@ 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 registered types -# (types//type.conf). Allowing arbitrary strings silently mis-registers an +# (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 diff --git a/scripts/lib/delivery-rulefile.sh b/scripts/lib/delivery-rulefile.sh index 6e5fda2..3aa06a3 100644 --- a/scripts/lib/delivery-rulefile.sh +++ b/scripts/lib/delivery-rulefile.sh @@ -3,7 +3,7 @@ # # 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 (types//_delivery.sh) is then a one-line +# 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 diff --git a/scripts/lib/type-registry.sh b/scripts/lib/type-registry.sh index c26de00..e7e3e52 100644 --- a/scripts/lib/type-registry.sh +++ b/scripts/lib/type-registry.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Agent-type registry. # -# Agent types are discovered from `types//type.conf` manifests instead of +# 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. @@ -11,7 +11,7 @@ # execute code. Multi-value keys are space-separated. # # Search order: -# 1. in-tree built-ins: /types +# 1. in-tree built-ins: /scripts/drivers/types # 2. external add-ons: ${AGMSG_HOME:-$HOME/.config/agmsg}/types # Built-in names are reserved; if the same name appears in both, the in-tree one # wins (listed first). @@ -32,7 +32,7 @@ _agmsg_type_search_dirs() { # this lib lives at /scripts/lib/type-registry.sh -> up two = root="$(cd "$_AGMSG_REGISTRY_LIB_DIR/../.." 2>/dev/null && pwd)" fi - [ -n "$root" ] && printf '%s\n' "$root/types" + [ -n "$root" ] && printf '%s\n' "$root/scripts/drivers/types" # ${HOME:-} keeps this safe under `set -u` with an empty environment. local ext="${AGMSG_HOME:-${HOME:-}/.config/agmsg}/types" [ -n "$root" ] && [ "$ext" = "$root/types" ] || printf '%s\n' "$ext" @@ -102,7 +102,7 @@ agmsg_type_get() { # Echo the absolute path to 's SKILL command template, resolved from the # manifest `template=` key relative to the type's own directory -# (types//template.md). Returns 1 if the type or its template= key is +# (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). diff --git a/scripts/session-start.sh b/scripts/session-start.sh index 2c2cbd3..9ae3022 100755 --- a/scripts/session-start.sh +++ b/scripts/session-start.sh @@ -48,7 +48,7 @@ PAIRS=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE" 2>/dev/null || true) [ -n "$PAIRS" ] || exit 0 # Type-specific SessionStart behaviour (Template Method). A type may ship -# types//_session-start.sh defining agmsg_session_start to override the +# 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 diff --git a/scripts/spawn.sh b/scripts/spawn.sh index dd472ac..bfe2950 100755 --- a/scripts/spawn.sh +++ b/scripts/spawn.sh @@ -164,7 +164,7 @@ if [ -n "$SPAWN_LAUNCHER" ]; then NODE_BIN="${AGMSG_NODE_BIN:-$(command -v node 2>/dev/null || true)}" [ -n "$NODE_BIN" ] || die "'node' not found on PATH — spawning '$AGENT_TYPE' requires Node.js" type_dir="$(agmsg_type_dir "$AGENT_TYPE")" \ - || die "agent type '$AGENT_TYPE' is not registered (no types/$AGENT_TYPE/type.conf)" + || die "agent type '$AGENT_TYPE' is not registered (no scripts/drivers/types/$AGENT_TYPE/type.conf)" SPAWN_AGENT="$type_dir/$SPAWN_LAUNCHER" [ -f "$SPAWN_AGENT" ] || die "spawn launcher not found for '$AGENT_TYPE': $SPAWN_AGENT" fi diff --git a/tests/smoke_windows_powershell.ps1 b/tests/smoke_windows_powershell.ps1 index 0f0053e..7ebc26e 100644 --- a/tests/smoke_windows_powershell.ps1 +++ b/tests/smoke_windows_powershell.ps1 @@ -51,15 +51,12 @@ $projectBob = Join-Path $testRoot 'project-bob' $projectMulti = Join-Path $testRoot 'project-multi' try { - $typesDir = Join-Path $skillDir 'types' - New-Item -ItemType Directory -Force -Path $scriptsDir, $typesDir, (Join-Path $skillDir 'db'), (Join-Path $skillDir 'teams'), $storageDir, $projectSingle, $projectBob, $projectMulti | Out-Null + New-Item -ItemType Directory -Force -Path $scriptsDir, (Join-Path $skillDir 'db'), (Join-Path $skillDir 'teams'), $storageDir, $projectSingle, $projectBob, $projectMulti | Out-Null + # Recursive copy brings scripts/ including the folded scripts/drivers/types/ + # manifests + per-type runtimes, so join.sh's type-registry validation resolves + # scripts/drivers/types//type.conf relative to the skill dir (the real + # installer ships them the same way via the single scripts/ copy in install.sh). Copy-Item -Recurse -Force -Path (Join-Path $RepoRoot 'scripts/*') -Destination $scriptsDir - # Ship the agent-type manifests too: join.sh and friends validate the agent - # type via the type registry, which resolves types//type.conf relative - # to the skill dir. Without this, `join … codex` fails with "Unknown agent - # type" (the real installer copies types/ via install.sh; this smoke stages - # the skill dir by hand, so it must mirror that). - Copy-Item -Recurse -Force -Path (Join-Path $RepoRoot 'types/*') -Destination $typesDir $wrapper = Join-Path $scriptsDir 'windows/agmsg.ps1' if (-not (Test-Path $wrapper)) { diff --git a/tests/test_helper.bash b/tests/test_helper.bash index ef36b64..2c88e76 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -11,20 +11,18 @@ setup_test_env() { chmod +x "$TEST_SKILL_DIR/scripts/"*.sh chmod +x "$TEST_SKILL_DIR/scripts/"*.js 2>/dev/null || true - # Copy the agent-type manifests so the type registry resolves types inside the - # sandbox (scripts/lib/type-registry.sh reads /types//type.conf). - # types// also holds each type's runtime now — codex's launcher/bridge/ - # shim/watch-once were folded out of scripts/codex/ into types/codex/. - mkdir -p "$TEST_SKILL_DIR/types" - cp -R "$BATS_TEST_DIRNAME"/../types/. "$TEST_SKILL_DIR/types/" - chmod +x "$TEST_SKILL_DIR/types/codex/"*.sh 2>/dev/null || true + # Agent-type manifests + per-type runtimes now live under scripts/drivers/types/ + # (the type registry reads /scripts/drivers/types//type.conf), + # so the recursive scripts/ copy above already brings them along — no separate + # copy is needed. Just ensure codex's folded runtime scripts stay executable. + chmod +x "$TEST_SKILL_DIR/scripts/drivers/types/codex/"*.sh 2>/dev/null || true # Initialize DB bash "$TEST_SKILL_DIR/scripts/internal/init-db.sh" # Convenience vars export SCRIPTS="$TEST_SKILL_DIR/scripts" - export TYPES="$TEST_SKILL_DIR/types" + export TYPES="$TEST_SKILL_DIR/scripts/drivers/types" # Sandbox HOME so NO test can touch the developer's real home. Several paths # write under $HOME — e.g. codex-shim-install.sh creates $HOME/.agents/bin/codex diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index 3022f1a..e2f0ada 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -6,8 +6,8 @@ # whoami/join/spawn/delivery suites; these lock the registry primitives and the # six built-in manifests themselves. # -# setup_test_env copies both scripts/ and types/ into TEST_SKILL_DIR, so the lib -# resolves /types there. Each case sources the lib in a wiped env so +# setup_test_env copies scripts/ (with scripts/drivers/types/) into TEST_SKILL_DIR, so the lib +# resolves /scripts/drivers/types there. Each case sources the lib in a wiped env so # host vars (this is a Claude Code session — CLAUDE_CODE_SESSION_ID is set) cannot # leak into detection. @@ -16,13 +16,13 @@ load test_helper setup() { setup_test_env; } teardown() { teardown_test_env; } -# Write a node-launcher fixture type into TEST_SKILL_DIR/types so the suite +# Write a node-launcher fixture type into TEST_SKILL_DIR/scripts/drivers/types so the suite # exercises the spawn= (Node launcher) mechanism generically, with no dependency # on any real external add-on: # - "nodetype": a node-launcher type whose manifest sets spawn= to a .mjs, with # a stub launcher file beside the manifest. write_node_launcher_fixtures() { - local nd="$TEST_SKILL_DIR/types/nodetype" + local nd="$TEST_SKILL_DIR/scripts/drivers/types/nodetype" mkdir -p "$nd" printf 'name=nodetype\ntemplate=cmd.nodetype.md\nspawn=nodetype-launcher.mjs\n' \ > "$nd/type.conf" @@ -118,7 +118,7 @@ write_node_launcher_fixtures() { @test "type-registry: manifests are DATA — never executed" { # An adversarial value must be read as a literal string, not run. - local dir="$TEST_SKILL_DIR/types/evil" + local dir="$TEST_SKILL_DIR/scripts/drivers/types/evil" mkdir -p "$dir" printf 'name=evil\ncli=$(touch %s/PWNED)\n' "$BATS_TEST_TMPDIR" > "$dir/type.conf" run env -i PATH="$PATH" bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get evil cli" From c0e5adbeb7f3956957a7abc2ef8030886e8a14dd Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 16:03:13 -0700 Subject: [PATCH 21/27] feat(registry): axis-generic driver discovery + external-plugin opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce driver-registry.sh: the axis-generic search bases + trust policy shared by all driver axes (types now; storage/delivery later). Discovery order is scripts/drivers (built-in) -> /plugins -> $AGMSG_PLUGIN_DIRS, with later bases overriding earlier ones among eligible candidates. External drivers are shell code run with the user's privileges, so they are NEVER loaded unless explicitly opted into. A built-in is always trusted; anything under plugins/ or AGMSG_PLUGIN_DIRS is ignored — with a clear stderr warning — until 'agmsg plugin trust /'. Trust is path-pinned (the db/trusted-plugins allowlist records axis/name -> abs path), so swapping a directory under a trusted name does not silently activate new code. - type-registry.sh becomes the types-axis facade over driver-registry; the AGMSG_TYPES_ROOT override and the ~/.config/agmsg/types external path are removed (root is always resolved from the lib's own location) - plugin.sh: 'agmsg plugin list|trust |untrust ' (ref is axis/name or an unambiguous bare name); wired into the Windows dispatcher - docs: ADR 0002 records the discovery + opt-in decision (supersedes ADR 0001's deferred-loader note and ~/.agents/agmsg/plugins path); spec + agent-types updated - tests: test_plugin_registry.bats locks ignored-until-trusted, later-wins override, path-pinned trust, the warning, and the CLI --- ...0002-driver-discovery-and-plugin-opt-in.md | 95 +++++++++++++++ docs/agent-types.md | 26 ++-- docs/spec/driver-interface.md | 6 +- scripts/lib/driver-registry.sh | 88 ++++++++++++++ scripts/lib/type-registry.sh | 81 ++++++++----- scripts/plugin.sh | 102 ++++++++++++++++ scripts/windows/dispatch.sh | 8 ++ tests/test_plugin_registry.bats | 114 ++++++++++++++++++ 8 files changed, 478 insertions(+), 42 deletions(-) create mode 100644 docs/adr/0002-driver-discovery-and-plugin-opt-in.md create mode 100644 scripts/lib/driver-registry.sh create mode 100755 scripts/plugin.sh create mode 100644 tests/test_plugin_registry.bats 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 index a010240..56e03d0 100644 --- a/docs/agent-types.md +++ b/docs/agent-types.md @@ -68,25 +68,31 @@ node /.mjs --name --team --project --initi 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 -(under `${AGMSG_HOME:-$HOME/.config/agmsg}/types`) with no built-in edits. +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 `types//type.conf` with at least `name`, `template`, and - `hooks_file` (add `detect`/`detect_proc` for auto-detection, and `cli` + - `spawnable=yes` if `spawn.sh` should launch it). -2. Add the command template `templates/cmd..md`. -3. If the type needs a delivery hook format that doesn't exist yet, add an - `apply_settings_` path in `delivery.sh`. Reusing an existing format needs - no code. +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 `types/` are the reference. For instance -`types/codex/type.conf`: +The six built-in manifests under `scripts/drivers/types/` are the reference. For +instance `scripts/drivers/types/codex/type.conf`: ``` name=codex 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/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/type-registry.sh b/scripts/lib/type-registry.sh index e7e3e52..83fe679 100644 --- a/scripts/lib/type-registry.sh +++ b/scripts/lib/type-registry.sh @@ -10,11 +10,11 @@ # A small per-key reader is used, so a third-party add-on's manifest cannot # execute code. Multi-value keys are space-separated. # -# Search order: -# 1. in-tree built-ins: /scripts/drivers/types -# 2. external add-ons: ${AGMSG_HOME:-$HOME/.config/agmsg}/types -# Built-in names are reserved; if the same name appears in both, the in-tree one -# wins (listed first). +# 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. @@ -25,43 +25,64 @@ # queried later. _AGMSG_REGISTRY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" -# Echo the type search directories, one per line (in-tree first). -_agmsg_type_search_dirs() { - local root="${AGMSG_TYPES_ROOT:-}" - if [ -z "$root" ]; then - # this lib lives at /scripts/lib/type-registry.sh -> up two = - root="$(cd "$_AGMSG_REGISTRY_LIB_DIR/../.." 2>/dev/null && pwd)" - fi - [ -n "$root" ] && printf '%s\n' "$root/scripts/drivers/types" - # ${HOME:-} keeps this safe under `set -u` with an empty environment. - local ext="${AGMSG_HOME:-${HOME:-}/.config/agmsg}/types" - [ -n "$root" ] && [ "$ext" = "$root/types" ] || printf '%s\n' "$ext" +# 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. +# Echo the directory holding /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" d - while IFS= read -r d; do - [ -n "$d" ] || continue - [ -f "$d/$want/type.conf" ] && { printf '%s\n' "$d/$want"; return 0; } + 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 < # 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/windows/dispatch.sh b/scripts/windows/dispatch.sh index 92374b7..c61665c 100755 --- a/scripts/windows/dispatch.sh +++ b/scripts/windows/dispatch.sh @@ -205,6 +205,14 @@ case "$COMMAND" in fi ;; + plugin) + if [ "$#" -eq 0 ]; then + run_script plugin.sh list + else + run_script plugin.sh "$@" + fi + ;; + mode) case "$#" in 0) diff --git a/tests/test_plugin_registry.bats b/tests/test_plugin_registry.bats new file mode 100644 index 0000000..9c5736f --- /dev/null +++ b/tests/test_plugin_registry.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +# External-plugin discovery + opt-in (trust) gating for the driver registry. +# External drivers run shell code with the user's privileges, so they are NEVER +# loaded unless explicitly trusted (`agmsg plugin trust`). These tests lock that +# contract for the "types" axis: ignored-until-trusted, later-wins override, +# path-pinned trust (no swap), the warning, and the plugin CLI. +# +# for the registry is TEST_SKILL_DIR (driver-registry resolves up two from +# scripts/lib/), so the default external base is $TEST_SKILL_DIR/plugins and the +# trust allowlist is $TEST_SKILL_DIR/db/trusted-plugins. + +load test_helper + +setup() { setup_test_env; } +teardown() { teardown_test_env; } + +# Make a minimal external 'types' driver at /types/ with a distinctive +# hooks_file so overrides are observable. +mk_ext_type() { + local base="$1" name="$2" hooks="${3:-.x/$2.json}" + local d="$base/types/$name" + mkdir -p "$d" + printf 'name=%s\ntemplate=template.md\nhooks_file=%s\n' "$name" "$hooks" > "$d/type.conf" + printf '# external template\n' > "$d/template.md" +} + +known() { + bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" 2>/dev/null +} + +@test "plugin: an external type under /plugins is ignored until trusted" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + run known + ! echo "$output" | grep -qx foo + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir foo" + [ "$status" -ne 0 ] +} + +@test "plugin: an untrusted external type warns with the opt-in command" { + mk_ext_type "$TEST_SKILL_DIR/plugins" bar + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types >/dev/null" + echo "$output" | grep -q "not trusted" + echo "$output" | grep -q "agmsg plugin trust types/bar" +} + +@test "plugin trust: makes an external type discoverable" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + run bash "$SCRIPTS/plugin.sh" trust types/foo + [ "$status" -eq 0 ] + run known + echo "$output" | grep -qx foo + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get foo hooks_file" + [ "$output" = ".x/foo.json" ] +} + +@test "plugin trust: a trusted external overrides a built-in (later-wins)" { + # Built-in codex hooks_file is .codex/hooks.json; the trusted plugin shadows it. + mk_ext_type "$TEST_SKILL_DIR/plugins" codex ".over/codex.json" + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".codex/hooks.json" ] # untrusted: built-in still wins + bash "$SCRIPTS/plugin.sh" trust types/codex + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_get codex hooks_file" + [ "$output" = ".over/codex.json" ] # trusted: plugin overrides +} + +@test "plugin: AGMSG_PLUGIN_DIRS supplies external types (when trusted)" { + local ext="$TEST_SKILL_DIR/extra" + mk_ext_type "$ext" baz + run bash -c "export AGMSG_PLUGIN_DIRS='$ext'; source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" + ! echo "$output" | grep -qx baz + AGMSG_PLUGIN_DIRS="$ext" bash "$SCRIPTS/plugin.sh" trust types/baz + run bash -c "export AGMSG_PLUGIN_DIRS='$ext'; source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u" + echo "$output" | grep -qx baz +} + +@test "plugin: trust is path-pinned — a name trusted at a different path is not honored" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + # Hand-write a trust entry pointing somewhere else; the real dir must stay ignored. + mkdir -p "$TEST_SKILL_DIR/db" + printf 'types/foo\t/some/other/path\n' > "$TEST_SKILL_DIR/db/trusted-plugins" + run bash -c "source '$SCRIPTS/lib/type-registry.sh'; agmsg_type_dir foo" + [ "$status" -ne 0 ] +} + +@test "plugin untrust: revokes a previously trusted external type" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + bash "$SCRIPTS/plugin.sh" trust types/foo + run known; echo "$output" | grep -qx foo + bash "$SCRIPTS/plugin.sh" untrust types/foo + run known; ! echo "$output" | grep -qx foo +} + +@test "plugin trust: a bare ambiguous name errors and lists axis-qualified candidates" { + mkdir -p "$TEST_SKILL_DIR/plugins/types/dup" "$TEST_SKILL_DIR/plugins/storage/dup" + printf 'name=dup\n' > "$TEST_SKILL_DIR/plugins/types/dup/type.conf" + printf 'name=dup\n' > "$TEST_SKILL_DIR/plugins/storage/dup/driver.conf" + run bash "$SCRIPTS/plugin.sh" trust dup + [ "$status" -ne 0 ] + echo "$output" | grep -q "ambiguous" + echo "$output" | grep -q "types/dup" + echo "$output" | grep -q "storage/dup" +} + +@test "plugin list: marks built-in, trusted, and UNTRUSTED states" { + mk_ext_type "$TEST_SKILL_DIR/plugins" foo + bash "$SCRIPTS/plugin.sh" trust types/foo + mk_ext_type "$TEST_SKILL_DIR/plugins" qux + run bash "$SCRIPTS/plugin.sh" list + [ "$status" -eq 0 ] + echo "$output" | grep -E "types/codex .*builtin" + echo "$output" | grep -E "types/foo .*trusted" + echo "$output" | grep -E "types/qux .*UNTRUSTED" +} From 1b3757dc32937f7108d0ba717d447dfb3c5812e3 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 16:18:02 -0700 Subject: [PATCH 22/27] docs(agent-types): refresh manifest table + paths for the 1.1.0 layout The top half still described the pre-1.1.0 layout: types/ at the repo root, templates/ for command templates, and apply_settings_* per-type code in delivery.sh. Update to scripts/drivers/types//, the type-dir-relative template= file, and the _delivery.sh Template Method plug. Add the delivery_modes / stop_output / hook_windows_wrap manifest keys to the table and the codex worked example. --- docs/agent-types.md | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/agent-types.md b/docs/agent-types.md index 56e03d0..b3fca42 100644 --- a/docs/agent-types.md +++ b/docs/agent-types.md @@ -5,26 +5,30 @@ copilot, opencode — and each is described by a small **manifest** so that the 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**, not an edit across -`whoami.sh`, `join.sh`, `spawn.sh`, and `delivery.sh`. +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 `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. +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 in `templates/` (becomes `SKILL.md`) | +| `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 Monitor tool; `spawn` skips the readiness wait when `no` | +| `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 @@ -50,10 +54,14 @@ cannot execute code. Multi-value keys are whitespace-separated. ### Delivery -`hooks_file=` is the per-project file delivery hooks are written into. The hook -*format* (settings JSON vs. rule markdown vs. …) is still per-type code in -`delivery.sh` (`apply_settings_*`); the manifest drives the file **path** and the -allowed modes. +`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) @@ -96,11 +104,14 @@ instance `scripts/drivers/types/codex/type.conf`: ``` name=codex -template=cmd.codex.md +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 ``` From 7b9a59e5cda63fd95c1b9ed482239aadaf1958e1 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 16:50:28 -0700 Subject: [PATCH 23/27] docs(plugins): add docs/plugins.md + README section + plugins/ drop-in dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the external-driver plugin system the way codex-monitor-beta is documented — a brief README section pointing to a dedicated doc: - docs/plugins.md: discovery order, the opt-in trust model (path-pinned, ignored-until-trusted), the 'agmsg plugin' CLI, and how to author a types plugin - README: a concise Plugins section + AGMSG_PLUGIN_DIRS in the env-var table; also refresh the Architecture tree (scripts/drivers/types, plugins/; drop the stale templates/) and fix a leftover types/codex path - plugins/: the default external drop-in dir, shipped with a README (and a .gitignore so locally-dropped test plugins/opt-ins stay untracked); install.sh now creates /plugins/ and ships the README --- README.md | 25 ++++++++-- docs/plugins.md | 121 +++++++++++++++++++++++++++++++++++++++++++++ install.sh | 10 ++++ plugins/.gitignore | 5 ++ plugins/README.md | 32 ++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 docs/plugins.md create mode 100644 plugins/.gitignore create mode 100644 plugins/README.md diff --git a/README.md b/README.md index 3e7adf0..155766f 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,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//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). +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 @@ -375,6 +375,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. @@ -475,8 +476,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) └── / @@ -490,6 +493,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/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/install.sh b/install.sh index fcc97e9..ff363b2 100755 --- a/install.sh +++ b/install.sh @@ -257,6 +257,11 @@ if [ "$UPDATE_ONLY" = true ]; then # 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/" + # 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 @@ -325,6 +330,11 @@ sed "s/__SKILL_NAME__/$CMD_NAME/g" "$(agmsg_type_template_path "$TPL_TYPE")" > " # 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/" +# 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 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. From 44cefa92ac0b2ee048353fba07d13f47140b9dc7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 19:14:48 -0700 Subject: [PATCH 24/27] feat(hermes): add Hermes Agent as a beta agent type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates @Fewmanism's Hermes support (#118) and spawn profile (#119), adapted to the 1.1.0 driver layout (scripts/drivers/types//) instead of the old hardcoded per-type branches and templates/ dir. - scripts/drivers/types/hermes/{type.conf,template.md,_delivery.sh}: a manual- only type (delivery_modes=off — no automatic delivery hook). The _delivery.sh plug makes apply/status/teardown no-ops so 'set off' writes nothing and never disturbs another type's watcher; cli=hermes + spawnable=yes make 'spawn hermes' work through the generic data-driven path. - delivery.sh: gate the in-session stop directive on the type actually having an automatic mode (data-driven via delivery_modes), so a manual-only type emits no AGMSG-DIRECTIVE on 'set off'. No per-type branch. - install.sh: ship the Hermes skill to ~/.hermes/skills// and accept --agent-type hermes for the shared SKILL.md, both via agmsg_type_template_path. - tests: hermes delivery (5) + install (5) + join, and the registry invariants (now 7 built-ins; spawnable set gains hermes; no-branch guard covers hermes). Deferred: #119's --hermes-profile spawn flag. It is inherently hermes-specific and the 1.1.0 layout forbids per-type branches in spawn.sh; it needs a generic spawn-profile mechanism (manifest key or spawn plug), tracked as a follow-up. Generic 'spawn hermes ' (default Hermes profile, actas via boot prompt) works today. Co-authored-by: Fewmanism --- install.sh | 22 +++- scripts/delivery.sh | 9 +- scripts/drivers/types/hermes/_delivery.sh | 21 ++++ scripts/drivers/types/hermes/template.md | 124 ++++++++++++++++++++++ scripts/drivers/types/hermes/type.conf | 8 ++ tests/test_delivery.bats | 53 +++++++++ tests/test_install.bats | 51 +++++++++ tests/test_team.bats | 6 ++ tests/test_type_registry.bats | 10 +- 9 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 scripts/drivers/types/hermes/_delivery.sh create mode 100644 scripts/drivers/types/hermes/template.md create mode 100644 scripts/drivers/types/hermes/type.conf diff --git a/install.sh b/install.sh index ff363b2..d958f1b 100755 --- a/install.sh +++ b/install.sh @@ -250,7 +250,7 @@ if [ "$UPDATE_ONLY" = true ]; then # shared SKILL.md; their dedicated copies are dropped separately below.) TPL_TYPE="codex" case "$AGENT_TYPE" in - gemini|antigravity|opencode) TPL_TYPE="$AGENT_TYPE" ;; + 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/) @@ -283,6 +283,12 @@ if [ "$UPDATE_ONLY" = true ]; then mkdir -p "$OPENCODE_SKILL_DIR" 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 @@ -323,7 +329,7 @@ mkdir -p "$SKILL_DIR"/{scripts,types,db,agents} # codex template by default; gemini/antigravity/opencode get their own. TPL_TYPE="codex" case "$AGENT_TYPE" in - gemini|antigravity|opencode) TPL_TYPE="$AGENT_TYPE" ;; + 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 @@ -390,6 +396,18 @@ if [ -d "$HOME/.config/opencode" ]; then 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/scripts/delivery.sh b/scripts/delivery.sh index d1efed8..213c8c5 100755 --- a/scripts/delivery.sh +++ b/scripts/delivery.sh @@ -360,7 +360,14 @@ do_set() { # Type-specific teardown via the plug (default: stop this project's # watchers; codex stops its bridge instead). agmsg_delivery_on_disable "$TYPE" "$PROJECT" - emit_stop_directive + # 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 } 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/tests/test_delivery.bats b/tests/test_delivery.bats index 68f2327..c6b98af 100644 --- a/tests/test_delivery.bats +++ b/tests/test_delivery.bats @@ -1309,3 +1309,56 @@ EOF [ ! -f "$TEST_SKILL_DIR/run/codex-bridge.team.alice.meta" ] kill "$bpid" 2>/dev/null || true } + +# --- hermes (manual-only: delivery_modes=off, no automatic hook) --- + +@test "delivery hermes: status is manual/off" { + run bash "$SCRIPTS/delivery.sh" status hermes "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" =~ "mode: off" ]] +} + +@test "delivery hermes: rejects automatic modes" { + local mode + for mode in turn monitor both; do + run bash "$SCRIPTS/delivery.sh" set "$mode" hermes "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "not supported for hermes" ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] + done +} + +@test "delivery hermes: rejects unknown mode" { + run bash "$SCRIPTS/delivery.sh" set bogus hermes "$TEST_PROJECT" + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown mode" ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] +} + +@test "delivery hermes: accepts off without writing hook config" { + run bash "$SCRIPTS/delivery.sh" set off hermes "$TEST_PROJECT" + [ "$status" -eq 0 ] + [[ "$output" =~ "Delivery mode set to 'off'" ]] + [[ "$output" =~ "manual inbox checks only" ]] + [[ "$output" != *"AGMSG-DIRECTIVE"* ]] + [ ! -e "$TEST_PROJECT/.hermes/agmsg.json" ] +} + +@test "delivery hermes: set off does not stop Claude Code watchers for the same project" { + mkdir -p "$TEST_SKILL_DIR/teams/myteam" + cat > "$TEST_SKILL_DIR/teams/myteam/config.json" </dev/null || true + wait 2>/dev/null || true +} diff --git a/tests/test_install.bats b/tests/test_install.bats index 17346e7..4429361 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -457,3 +457,54 @@ PY fi } + +# --- hermes Agent skill (~/.hermes/skills//SKILL.md) --- + +@test "install: drops a Hermes skill when ~/.hermes exists" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + local hermes_skill="$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" + [ -f "$hermes_skill" ] + grep -q "whoami.sh \"\$(pwd)\" hermes" "$hermes_skill" + grep -q "^name: agmsg" "$hermes_skill" + grep -q "~/.agents/skills/agmsg/scripts" "$hermes_skill" +} + +@test "install: custom command name is substituted in Hermes skill" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd msg + local hermes_skill="$FAKE_HOME/.hermes/skills/msg/SKILL.md" + [ -f "$hermes_skill" ] + grep -q "^name: msg" "$hermes_skill" + grep -q "~/.agents/skills/msg/scripts" "$hermes_skill" + grep -q "You can now use \`/msg\`" "$hermes_skill" + ! grep -q "__SKILL_NAME__" "$hermes_skill" +} + +@test "install: --agent-type hermes makes shared SKILL.md Hermes-typed" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg --agent-type hermes + grep -q "whoami.sh \"\$(pwd)\" hermes" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" codex" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" gemini" "$SK/SKILL.md" + ! grep -q "whoami.sh \"\$(pwd)\" antigravity" "$SK/SKILL.md" +} + +@test "install --update: refreshes the Hermes skill if it was previously installed" { + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + local hermes_skill="$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" + [ -f "$hermes_skill" ] + echo "tampered" > "$hermes_skill" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + ! grep -q "^tampered$" "$hermes_skill" + grep -q "whoami.sh \"\$(pwd)\" hermes" "$hermes_skill" +} + +@test "install --update: installs Hermes skill for upgraders without prior skill" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ ! -d "$FAKE_HOME/.hermes/skills/agmsg" ] + mkdir -p "$FAKE_HOME/.hermes" + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + [ -f "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" ] + grep -q "whoami.sh \"\$(pwd)\" hermes" "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" +} diff --git a/tests/test_team.bats b/tests/test_team.bats index 3465114..5cf9e5a 100644 --- a/tests/test_team.bats +++ b/tests/test_team.bats @@ -347,3 +347,9 @@ teardown() { [ "$status" -eq 0 ] [ -f "$TEST_SKILL_DIR/teams/テストチーム/config.json" ] } + +@test "join: accepts hermes" { + run bash "$SCRIPTS/join.sh" myteam alice hermes /tmp/proj + [ "$status" -eq 0 ] + [ -f "$TEST_SKILL_DIR/teams/myteam/config.json" ] +} diff --git a/tests/test_type_registry.bats b/tests/test_type_registry.bats index e2f0ada..209d8f8 100644 --- a/tests/test_type_registry.bats +++ b/tests/test_type_registry.bats @@ -29,11 +29,11 @@ write_node_launcher_fixtures() { printf '// stub node launcher fixture\n' > "$nd/nodetype-launcher.mjs" } -@test "type-registry: known_types lists the six built-ins" { +@test "type-registry: known_types lists the seven built-ins" { run env -i PATH="$PATH" bash -c \ "source '$SCRIPTS/lib/type-registry.sh'; agmsg_known_types | sort -u | paste -sd, -" [ "$status" -eq 0 ] - [ "$output" = "antigravity,claude-code,codex,copilot,gemini,opencode" ] + [ "$output" = "antigravity,claude-code,codex,copilot,gemini,hermes,opencode" ] } @test "type-registry: is_known_type accepts a built-in and rejects a bogus type" { @@ -64,7 +64,7 @@ write_node_launcher_fixtures() { [ "$status" -ne 0 ] } -@test "type-registry: spawnable set is exactly claude-code and codex" { +@test "type-registry: spawnable set is exactly claude-code, codex and hermes" { run env -i PATH="$PATH" bash -c \ "source '$SCRIPTS/lib/type-registry.sh' while IFS= read -r t; do @@ -72,7 +72,7 @@ write_node_launcher_fixtures() { [ \"\$(agmsg_type_get \"\$t\" spawnable)\" = yes ] && echo \"\$t\" done <<< \"\$(agmsg_known_types | sort -u)\" | paste -sd, -" [ "$status" -eq 0 ] - [ "$output" = "claude-code,codex" ] + [ "$output" = "claude-code,codex,hermes" ] } @test "type-registry: detection manifests carry the expected env / proc keys" { @@ -159,7 +159,7 @@ write_node_launcher_fixtures() { # join.sh and spawn.sh must be fully data-driven; whoami.sh is allowed only its # default fallback (echo "claude-code"). Any other type literal on a non-comment # line is a re-introduced per-type branch. - local types='claude-code|codex|gemini|antigravity|copilot|opencode' + local types='claude-code|codex|gemini|antigravity|copilot|opencode|hermes' for f in join.sh spawn.sh; do run bash -c "sed 's/#.*//' '$SCRIPTS/$f' | grep -nE '$types' || true" [ -z "$output" ] || { echo "hardcoded type literal in $f:"; echo "$output"; false; } From ea6dea8e0819c0f5e5df6c34e05c549823cbc724 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 19:46:21 -0700 Subject: [PATCH 25/27] fix(install): re-point an existing Codex monitor shim on --update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~/.agents/bin/codex shim bakes in the absolute path to codex-shim.sh at generation time. install.sh --update copies the relocated script (scripts/drivers/types/codex/) but did not re-run codex-shim-install.sh, so a shim generated before the types/ -> scripts/drivers/types/ move kept execing its old, now-missing path — breaking Codex monitor for users upgrading from 1.0.x. (Found in review by aggie-co1.) Both install paths now re-run codex-shim-install.sh when an agmsg shim is already present (status reports 'installed'). install is idempotent and overwrites only an agmsg shim — a user's own codex binary fails is_agmsg_shim and is left untouched; when no shim exists the refresh is a no-op, so --update never opts a user into the shim. Covered by two install.bats cases (re-point on update; no shim created when none existed). --- install.sh | 18 ++++++++++++++++++ tests/test_install.bats | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/install.sh b/install.sh index d958f1b..5bb6cf9 100755 --- a/install.sh +++ b/install.sh @@ -292,6 +292,17 @@ if [ "$UPDATE_ONLY" = true ]; then 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" @@ -345,6 +356,13 @@ cp "$SCRIPT_DIR/plugins/README.md" "$SKILL_DIR/plugins/README.md" 2>/dev/null || 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 diff --git a/tests/test_install.bats b/tests/test_install.bats index 4429361..893cef6 100644 --- a/tests/test_install.bats +++ b/tests/test_install.bats @@ -508,3 +508,33 @@ PY [ -f "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" ] grep -q "whoami.sh \"\$(pwd)\" hermes" "$FAKE_HOME/.hermes/skills/agmsg/SKILL.md" } + +@test "install: --update re-points an existing Codex monitor shim to the new path" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + # Install the shim the way enabling Codex monitor mode would. + HOME="$FAKE_HOME" bash "$SK/scripts/drivers/types/codex/codex-shim-install.sh" install >/dev/null + local shim="$FAKE_HOME/.agents/bin/codex" + [ -f "$shim" ] + grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + + # Simulate a shim baked by a pre-1.1.0 layout (stale exec path), keeping the + # agmsg marker so it is still recognized as ours. + local tmp; tmp="$(mktemp)" + sed 's#/scripts/drivers/types/codex/#/scripts/codex/#g' "$shim" > "$tmp" + mv "$tmp" "$shim" + grep -q '/scripts/codex/codex-shim.sh' "$shim" + ! grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + + # --update must regenerate it back to the post-move path. + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + grep -q '/scripts/drivers/types/codex/codex-shim.sh' "$shim" + ! grep -q '/scripts/codex/codex-shim.sh' "$shim" +} + +@test "install: --update does NOT create a Codex shim when none was installed" { + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --cmd agmsg + [ ! -e "$FAKE_HOME/.agents/bin/codex" ] + HOME="$FAKE_HOME" bash "$REPO_ROOT/install.sh" --update + # The refresh is gated on an existing agmsg shim — it must not opt the user in. + [ ! -e "$FAKE_HOME/.agents/bin/codex" ] +} From a17a727eaf9f9fb54ff28882608cc5a7bc99d784 Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 19:50:13 -0700 Subject: [PATCH 26/27] docs(install): list hermes in the --agent-type help (co1 nit) --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 5bb6cf9..f2cb1f2 100755 --- a/install.sh +++ b/install.sh @@ -171,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)" From 73a53ae12c91e6256fe6d14ea85d58c2ad048d8e Mon Sep 17 00:00:00 2001 From: fujibee Date: Sun, 21 Jun 2026 21:30:07 -0700 Subject: [PATCH 27/27] docs(readme): add supported-agents logo strip Feature the seven supported agent runtimes (Claude Code, Codex, Gemini, GitHub Copilot, Antigravity, OpenCode, Hermes) as a white-on-black logo strip under the intro. Source marks live in docs/logos/; the composited strip is docs/logos/supported-agents.png. --- README.md | 5 +++++ docs/logos/antigravity.png | Bin 0 -> 7437 bytes docs/logos/claude.svg | 1 + docs/logos/codex.svg | 1 + docs/logos/copilot.svg | 1 + docs/logos/gemini.svg | 1 + docs/logos/hermes.png | Bin 0 -> 12333 bytes docs/logos/opencode.svg | 1 + docs/logos/supported-agents.png | Bin 0 -> 21662 bytes 9 files changed, 10 insertions(+) create mode 100644 docs/logos/antigravity.png create mode 100644 docs/logos/claude.svg create mode 100644 docs/logos/codex.svg create mode 100644 docs/logos/copilot.svg create mode 100644 docs/logos/gemini.svg create mode 100644 docs/logos/hermes.png create mode 100644 docs/logos/opencode.svg create mode 100644 docs/logos/supported-agents.png diff --git a/README.md b/README.md index 155766f..75a7c0e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ Cross-agent messaging for CLI AI agents. No daemon, no network, no complexity. You stop being the copy-paste courier between your agents. Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, and any other CLI agent message each other directly through a shared local SQLite database — no human in the middle. +

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

+ **What it isn't:** - Not MCP. No MCP server, no extra runtime — just `bash` + `sqlite3`. diff --git a/docs/logos/antigravity.png b/docs/logos/antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..9179031441a486d0ccc2973ff3009a3bf99aabde GIT binary patch literal 7437 zcmdT}i9eLz_n#TdFfqtdnz5H<$dYZyn(X@+OH@WaLRrSrLd8tesK`WSD8!)2k{FRC zVbF(9$W}60$46sHBJ0Qgd;0ztzj?i$*PQ!4=f3Yb=Q;O2_dd@lzriNDQVkK``ZHNO}>pdP$Gd64TrYWr9=oQE!o##x;3ajH5bb&!71b^7W* zPd^v4`O**ecg&0BSJVho0!VGbVeJH`%ES-D^e9vVWJRHAU;RMR0w)B4#-hlN)SCGp zsr`7HdI7YhM1Cv+j22ik9|4^UiOQcgA1t~@S+%+uE6+v z`Rb!1dd=5l8{G;qQorLdEq5Kq50qGzhm$@??0`a z$>IC4kq9Zlzz|#Xiav#~F_ru$*@nIef^=UL((M^$=s(lzDx~PA;qj*4i-c{!E$goG zj!sbn*((_r!Bep{SX|J&<5DquT(B)Pw|Ju~2jsuf-+2PB9BxwH&?jxYTeW| zm`n>FQvg6XyaSh?Gx~qqf~v*QxXW!epPN7Y`X$*(Z|})%2e0m!-b?l3ZVcz2(1Yk6 zM&oRP_O8KvYXwQo+NX&;m!GtCyuyFA(aJMUm6!=i;<@bRuq@YZ;}98E!E)k-qk&8t z{q>z$B^aM^-!7lgUnR^@^+n;|pO2s9HSG#mxg$tpi2-(JwiS&X6MOIGg%2)dfhdIx zh{#_NfoLtvRqh3l2oMnugZ0~}24iz6kB34Bv~InlY+j|0wDL)wo1&6%v|A|SHryu2 zQ%V+ynJ1JXq)wX?9bg;ZoL4< z3x&iRn#OPZP61=;b6DUyJAZkPqY^>pIcwWS1i#Qy*2!GeF5-&d3s)cvN6@7E6ahii zXFId@qaCde!6ORVz=m$)90Av{YRII=6d|%C0)Kq7oF$A!bCTci@0jtzDXS))MrORr zTEJN2t_opzlWOF$Fp(!2Gmbulp1^)BBc9<9DzS!7Gf>d;3V4f>2&RB1ydD5$hJ-We zU0^b+bs1~ulZJvyK%i%LkCq`cY-v1gMo13U{VV4@JgK;?wLcft<$yIhng@egd5q(% zfB6K2ppZh2*GRNIq_(ysJc;qHwX69%f?EiFd77(b?o5Fw8N-K1ZTNMd0u!d$Gzp|8blS!xx z4g2knY*htwv`%0ucH3dmN0U5#P=VH&1p>%NADFWGagy4iCnb~IlB~_4U;bWJZFif( zNd$w}bxgXn0qeT~?x@Q*sR||d{w4{7m5-Dhg0Zx0kN^r9tHnn#Z;u-Mvfx;T-xcjQ zl7zqWO0p&kU)iWH53?HPg~uE6ed$|P9si6exSC`g`e4fa-OD!1x?By1K%vKj`b1Km z(K%}7Zm>mX4R+28AkiHYa`ekJaj3(h=YZM{RtW2tMR>+Kl1Z9CftrPt^yiuAZGTu+ zHN{NPPji2!s?kjwP@8&mD+tsy8#2Omg1>vgICBF-o(}wvW;dGq(eegBn=sUlgIay)ad>rs8~x5J#<7(=n^a>mu0y?&5+!^t#8@gyudL**>lX3j=Z60~|jz=*!KRr9rQP zbG|4&`_p9NUfKxCA9Ady%`yhVzIm*Tf71AsN#8l|)aYV>lWyaL zrBK~LA(hlY-mJH+uXBwZ#Ifj-i$)h>#aYh1kYhlGAd3?9KpH?ql2pBnc*LeiF=Y@! zB$XLmkod*@;to82+#1ygJ0YhrrbN<6XfbQ=wo3_+?!}j&U)5@(=iN>cNmpo}tqs9H zt^CMkg2*pnFdUF`tTTsxnG&9~XZlvw@e?n+47)wx0or*Z>o~*=_ay9xX2=wiM3vv3{G~E~wpFz->qk*LqmZg4qds{gWyeDA# zFH7<1<64-f7`o&k^mtF_VigvsI~cbW&8K~&t`j3kB>6gldp;qdL!#&spwxQ$W|OZ7 zAirFK;Uc~UmL{=UKhhRz48a!BbrH=AhSlUEB4^SmzM+@AUPi#b?Ngp zekG3fNxkM%(lH%9qE&9g!8van6v)n?|8&hxfYGH6#F&ail71=P!c@64w_2G<@}xE; z5_x_UzHYn13;%kG&kZq(`qT5|*MI^u6!L$f2k32;Y&W2n@|F!r`b>B6V8H-mdf1`$ zQ=P3SSw0O7PqbN*uw=^)d#0Jc+n!0f~+0Hy;XNgKNoOi?0<;RLSfSgg_jJiiWDhI+6dfF({wYJ~eVMk?TB z=C^bA$us932VF-Yd%BD@Vl8P;zU@sC;+!q7*3Q!sKP5O+9{BICW~m=+-bt8`=$h>0 z(hjyJyC--1&V=|y`*9UT@m<*AsLr1rkibu9rPTX^6Cb?-hfa zs-q~RHHULl) zElflizw9P!vLkMQ@=Ur;rtFzI4>`~NT9$`E|G1{czRz?wDwvix3UdOB$m5qqJ(wZ^ zMPesn-wSLMy!%KJK$_h1fG(+o>Af72Tsc+W_jm+l0xU%*1t_!71H?&9criByz&H5}TQb5b8Z!_k62K_R!yU?Mp=;ej`%Urn{cUu|yhEhp443YfP|)Ri~>dXv@ZgAIq3h0_z3qT)53 zz_$*g*;&lrO|Mg_I@C|Jy4E_ABkUYjYjcf9MfZt~*;c#W&yEW}jl(53fFQIMsfZ?n zCDJFq4~|@6#gI!Zr`1>pdHi_hXpCn=kMwYepYmOK!dnd4(4_ zDf#OZ-!m`RjVe^*v=jW-aXkKrNZNx#>dZ05J$KuD+g+ce&?#Vj8M8ah)f(-02ymTw zzQ2O8m^DBl$2z_=c){*R$v-Vqz2P>+v6&zCt%wM_bbblg*ofgle(H%^1?9r+`Y7aW z6;r*{4|~cTG`Y-#*U`*1>szvoUSt3CLL%hx_>6(aVF|BjZDKa&qCKAm$Lt)*OF|Pa zQQk1ym|+JK$NVB!uy+X_@7!=DUVJ(_TQvs#xXMpGsdtOQYOS-$J2mVOAQco6{oP33 zaDGMRq~w0ka(H(=1?5Ug)s}=p?1$dw)*qJ z--z9}7EXRsMugh=OUs`4jFBE=wHNYRpBrcKyy*RIK_)3gUXLwQRnb`nhOnAHU(lvg z558|*H+9zCC9EGjDA(hZ>P!yAk>Dx5BnH3IMDtOXn_=_MD?BR~Mi!+jL5R zabCA#7;EQxuD)>%M0))NWwhzg*9fOms@jq)-LDt`#2L2#16#dTR_JpXgk<53XFac9 zsCfO=S=6TUbPdZvG>6Kpt&Js_+!+(ueo&pe}Pz|h6c$3jz(#mh=X~bXH zrt_fHD`1@8vD>CB+9zG+0jyKrrYypT#JD!U8ZQ+7PvsON|<$*;#WE=ZS1W>?CgMS#Q4K#O}B z;+JEAtyZ8A9D*RgY&-eOBEmi|JZsgB*Xz&znX)FGa0EE;6h$g|8r~C**Y7e_SGv%s zeVkb2!FU%XwYT$^8#PZjwyz6o@MDh38mI&awIJJ@em{NhH^q#_b@Ec+v>zQ3dolhn zE7grSw1h8seWM z8W*(R49fQv$Jo~`uKRd4J6 zBlyC=%eas+NL(ZCnr@3A`K_lAB%!pKg+qCKpFE~Q#^i^DUrW?mO3N?q!FG!0jc!Qk zxsr>bc(8^m&k(MVde3XhP&>n_ zoh7?=UP|qr^qofLVd$oM%kHffzd6rm=L*~*wnYxEG2(k8N!{OYk!rEu)qk3wZHZ6kSc^hIA1xt*%8^8W2$%n4S9ZxI%Kj4F$2l3M|4i#64CBx;G)I!RIn z#=lJP*B-^+HbMpw0{dj*Cgde48{dD}^l>r45vO_239! zyOhg68rV8A6rPJDM_8%zT^@?Ndcqr@7k9bFid-bdBb0oF!PO&y89bOIfD~3G z6`E#=yv5+aJT+cp%A=wuY5cJX{#^z&7@Yg_I>NjrQ9Zp3AB>LaE@fVFa(_Q>x{vrXDNg$L2Qq zW{la7)pTE96b(gk3m&MPZR0Q3^p>a_C@!9Fh?50QP$h0gmX)*Dv|Xp)m-G=KV58uv^0Z+Pm@J#-@o0hg@7ZB^I;8y)AZf z8#ln%SF3WS6;+k@GP@qqLVq}~&8ITK>+1d;O%Z8a(e>gu%4c!3OG!`o+B?Wj?-+kg zQCRF+Jnc@zk!Q1Los%?iojUNBlGPoro|xP;RuqI$lhxj#yj|oE6Gm{hsEI=B@pI>f zYOpR!a!rj7%^Fnrj(Pp^i<52Cnw@w$i&h{82F<7D?L278RG(D!KC@#ir#Sv%IKTA= zFGZ53Ft9q8q2NAeH*57GW5?@ja8lq29-)V3>I@&c()>5ttIWzm*CaddvyIwbz@mh3C>( zRrEFf-i1n2{|DdM(MLbfKG9@@-x^dMlKC2R1DA!XE|uhY(OBwMT~DFEsuO;zRz_)r67-1Dx(bEtEJd`@AaMo zFTBkLyOfy(#xRPq)(6Amo6Q6x7;RCF?>mM(*f%x9KyOubMH&Ww8*DRmEaD9%DG2KK zJ)`Yq&FkS3IjR;kW8+s=v}rgBX}G{m%Qiu7pJ9Wpc+GUJIG4XRe-%v&ghjk)`2LXATouWs*|ss{!d0{?jWi zW4DQ1NQMX%)c8a%v-zD5qepu8o%Xnf=o(1=B7cykwLz zZW%CIlEI4NAx^*Zojg9u_SEm?A*combFXyXFX@Jw!y=XO=iR%my(Lp_SEaYQ5hAHq zY~eDMMR|b(S0gvwQf`k<2FaLRG|rI2Y0bwyTheK$H+enD15cmOThel!&CjYp^WRKc z+sz$ZSmkc7JU+X?M9xHKQO*4;)S;;{kv@cJM)3SMZoIkd1bLT>CVjRz96YM zdHnR>Qz}m)0_)zbPtLFRc1f^8X>zx>j8oK__D!1%xa*6ncMy%jPv5J}Nhk0Ur@?~= z{T6$}s$11FhO;dfwh|9qe@^6%vYRaHD00yMPoL%f_)51qI5fF`vtIEb{@*`sdux}o J6=!gF{}14-6?Xst literal 0 HcmV?d00001 diff --git a/docs/logos/claude.svg b/docs/logos/claude.svg new file mode 100644 index 0000000..2cf56e9 --- /dev/null +++ b/docs/logos/claude.svg @@ -0,0 +1 @@ +Claude Code diff --git a/docs/logos/codex.svg b/docs/logos/codex.svg new file mode 100644 index 0000000..4c648af --- /dev/null +++ b/docs/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/docs/logos/copilot.svg b/docs/logos/copilot.svg new file mode 100644 index 0000000..8716d6f --- /dev/null +++ b/docs/logos/copilot.svg @@ -0,0 +1 @@ +GitHub Copilot \ No newline at end of file diff --git a/docs/logos/gemini.svg b/docs/logos/gemini.svg new file mode 100644 index 0000000..1111255 --- /dev/null +++ b/docs/logos/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/docs/logos/hermes.png b/docs/logos/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4a160ceb721402e21ae107cbea45bbd80702b5 GIT binary patch literal 12333 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�!X5p5sf&LloNSTYkUfT-^- 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 \ 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 0000000000000000000000000000000000000000..d5c1f8aae98a8c345d00432601cae071ecbef2d2 GIT binary patch literal 21662 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

D!;MOEabE1AkEA5?;jy#=`|E-cb? z5BF=T^nWBM^dIbZDZc=k@Z*3gyQFPLE&;`!?GSq*fqynQxKV(=O`swVAZuJ>O;}-2 zx3kCC$zbd_@)N#D7Eg)7NpLvH@$i6YOr?KQY#aLV$$**@>Y$OlCxf5GG2BOw&I3hc z8?eSdCyeU*Xgg`2R9X$yuW$kQ#0hU>+gU_=WS6rLbzWA$Ja7hIZ5*Y zk=-7>fbmiuJ=|m) zqV6m((Ou$kIVj8MRtlNl&xbEzZ0Yk2-%*R4bJF&_tUkQAORt53`uWI8GR&KEo zNGdu&QjxW18zJ)zJ0mbo2n9#TkPGdMZzV25r6otDd1!OsjimkI@#6CPgPr7Yh<+}x zO{x2LkE0doe5|;BK?I}zOUCF6jWmynUM7i3&49Vqpk8Isb0uSGt4>wI!YVz1i&x3%7lG-mV(V`&mTyGSW}=muJpiMP=Go1 z0QZ5)+tC6R;Qwt%lZOdGPtlvp#G?6~@h{(J{hBEp!$QRqahk%BxBesi+zOBQOYRAa zVB-phJ)-zy1me2pue8WRIY^XPL5G|C2mdKmWcWi%GGG>}e!up1VBh)jf6!}ji!}zhr@tU8IUq!Q#Z@}M`x*+_66C4 z@Qh+kgtH3NUwq&!;z;({Lo9Ys{bbtS%E;9UG^e}4j8LGub!-sLePyhKLA5FpmQ7W6 zW50^Sf_#L};=zaiZxD{pJtKc1$o(?M0|3ngl&eREQv}DN^F5;-PDP_l|MIxJO#_XD zYd;>RuWLMm!~-P&Ae&4QC9xS!p6Zl1HUGVgm`pjn%iGtQoTvpADNNq`~LT`T01rOQBE^m?|E}m{ zPlMc9e;0(LD+CctmAwI$jmz4r6$Y^`BZCR6R3;zFJ@{3wR(aL&y2hrzI5xc#WR2-# z2AYPeLfwiYi-A-s6^bNhX;GxJT6J7S=R|F!6*pCFyk+a9$J`R4#k3)I?jM+H)u=w; zs>2LV%2-RCwWbf{@+5vkj!;93qw_BC-4Vo7J zzH-*RwI)*`{-t`j6P3lbQJgL8Lfjk^m3MnFOcRY}URuOAXun5N&8hBQ+8yYVzUCe@ zW)9KN{iu{ZHzYiMmO3>P`{A+vo(?xu(XQZ7%;vll20k!CO;gdyqo+HCY1AZH>RwE8#AT&ex&loRkfF4KFoRF$g}^)8eq8qvB#CnS@uZp`G^F$ z