…sage
Adds CliChannelRegistry and CliChannelTransport under inc/Channels/.
CliChannelRegistry resolves a channel name to a normalized command
template via the datamachine_code_cli_channels filter and matching
option, with permissive validation that silently drops malformed
entries. It also owns positional token substitution for {recipient},
{message}, {conversation_id}, and {channel}.
CliChannelTransport claims wp_agent_dispatch_message_handler at
priority 20 when Environment::has_shell() succeeds and the requested
channel is registered. Otherwise it returns the existing handler so
other runtimes can claim the filter cleanly. The runtime supports
both detached (fire-and-forget, returns PID as message_id) and
synchronous (stdout/stderr/exit_code captured, timeout enforced)
delivery modes. Command and args are passed to proc_open as an array
so no shell interpolation occurs — message bodies containing shell
metacharacters are delivered to the child process untouched.
Bootstrap registration is gated on the agents-api substrate being
loaded (function_exists check for the canonical helper).
Closes #412
Summary
Implements the generic CLI transport runtime for
agents/dispatch-messagedescribed in #412. The runtime claims
wp_agent_dispatch_message_handlerwhen this host can spawn subprocesses and the requested channel is
registered in a config map. It then shells out via
proc_opento deliverthe message — zero per-transport code, adding a new outbound bridge is a
config entry.
The runtime has no knowledge of any specific CLI bridge. It is paired
with separate bridge installers (e.g. Extra-Chill/wp-coding-agents#129)
that write the channel→command-template entries during install.
Closes #412
Files
inc/Channels/CliChannelRegistry.php— config lookup via thedatamachine_code_cli_channelsfilter + matching option, permissivevalidation, and positional token substitution for
{recipient},{message},{conversation_id},{channel}.inc/Channels/CliChannelTransport.php— the runtime. Filter-resolverthat claims when shell + channel are available, declines (passes
through) when not. Implements both detach (fire-and-forget, PID as
message_id) and sync (stdout/stderr/exit_code captured, timeoutenforced) delivery modes via
proc_openwith array argv.tests/smoke-cli-channel-transport.php— 40-assertion smoke testcovering registry validation, token substitution (including shell
metacharacter safety), claim/decline semantics, and both delivery
modes using
/bin/echo,/bin/true,/bin/false,/bin/sleep.data-machine-code.php— bootstrap wiring, gated on the agents-apisubstrate being loaded.
Acceptance criteria
agents/dispatch-messageinvocations with a registeredchannelare routed to the CLI runtime (filter-resolver returns
[CliChannelTransport::class, 'execute']).Environment::has_shell()is false, the runtime declines andreturns the existing handler so the filter chain continues.
claim them.
— args are handed to
proc_openas an array;{message}is deliveredto the child as a single argv entry regardless of contents.
message_id; syncmode captures stdout/stderr/exit_code and respects
timeout.sent,channel,recipient,message_id,metadata).WP_Erroris returned onfailure (with machine-readable error codes:
datamachine_code_cli_dispatch_no_proc_open,_spawn_failed,_nonzero_exit,_timeout,_unknown_channel,_invalid_input).telegram, etc.) anywhere in the new code — verified via
grep -iE 'kimaki|discord|slack|telegram|whatsapp|cc-connect|signal' inc/Channels/ tests/smoke-cli-channel-transport.php -rreturningzero matches.
Smoke test output
```
=== smoke-cli-channel-transport ===
[PASS] valid entry normalizes
[PASS] normalized entry has args array
[PASS] normalized entry preserves detach false
[PASS] normalized entry preserves timeout
[PASS] missing command is rejected
[PASS] non-string arg is rejected
[PASS] option-defined channel is present
[PASS] filter-defined channel is present
[PASS] malformed entries are dropped
[PASS] lookup returns config for known channel
[PASS] lookup returns null for unknown channel
[PASS] recipient token substituted
[PASS] message token substituted verbatim (no shell interp)
[PASS] conversation_id token substituted
[PASS] channel token substituted
[PASS] literal arg untouched
[PASS] token substituted inside compound arg
[PASS] missing input substitutes empty string
[PASS] claims registered channel
[PASS] declines unknown channel (returns existing null)
[PASS] preserves prior handler at filter chain
[PASS] declines empty channel name
[PASS] sync success returns array
[PASS] sync success sent=true
[PASS] sync success channel echoes input
[PASS] sync success recipient echoes input
[PASS] sync success metadata mode=sync
[PASS] sync success exit_code 0
[PASS] sync success captures substituted stdout
[PASS] sync /bin/true succeeds
[PASS] nonzero exit returns WP_Error
[PASS] nonzero exit error code is machine-readable
[PASS] nonzero exit error data carries exit_code
[PASS] timeout returns WP_Error
[PASS] timeout error code is machine-readable
[PASS] unknown channel from execute returns WP_Error
[PASS] detached returns array
[PASS] detached sent=true
[PASS] detached message_id is numeric PID string
[PASS] detached metadata mode=detached
OK
```
Reproduce locally with:
```
php tests/smoke-cli-channel-transport.php
```
Design notes
Why a direct `add_filter` instead of the substrate's `register_dispatch_message_handler` helper
The substrate's convenience helper wraps the supplied handler in a
filter callback that returns it unconditionally (modulo a prior winner).
This runtime needs conditional decline semantics — pass through when
shell is unavailable, pass through when the channel is unknown — so it
registers `CliChannelTransport::maybe_claim()` on
`wp_agent_dispatch_message_handler` directly at priority 20, leaving
room for higher-precedence runtimes to win first. The behavior matches
the design described in the issue body.
Security
the registry, which is populated via filter/option by site admins.
is invoked with the array form so PHP bypasses the shell entirely.
the parent's `PATH` only — no other inherited variables leak.
`agents_dispatch_message_permission` filter; no new permission layer.
Output caps
Captured stdout/stderr is truncated to 8KB per stream to keep a runaway
child from blowing up the response payload.
Confirmation: no transport-specific names
```
$ grep -iE 'kimaki|discord|slack|telegram|whatsapp|cc-connect|signal' \
inc/Channels/ tests/smoke-cli-channel-transport.php -r
(no matches)
```
Related work
The wp-coding-agents config-write integration
(Extra-Chill/wp-coding-agents#129) is a separate PR that depends on
this one — that's where the actual per-bridge command templates get
written into the registry.
cc <@532385681268408341> ready for review.