Skip to content

feat(channels): generic CLI transport runtime for agents/dispatch-message #412

@chubes4

Description

@chubes4

Summary

Build a generic CLI transport runtime in DMC that registers as a handler for agents/dispatch-message (agents-api v0.107.0+). The runtime reads a channel→command-template config map and shells out via proc_open to deliver outbound messages. Zero per-transport code — adding a new CLI bridge (kimaki, cc-connect, telegram-cli, future) is a config entry, not a plugin.

This is the WordPress-side counterpart to wp-coding-agents's bash-based bridge installers. wp-coding-agents writes the channel config during bridge install; DMC consumes it at dispatch time.

Why DMC, not data-machine

The CLI runtime requires disk-side capability (Environment::has_shell()). That's DMC's gate by design:

  • DMC's whole identity is "this site can run external coding-agent runtimes." Subprocess exec is core to that.
  • \DataMachineCode\Environment::has_shell() is the exact capability check this runtime needs.
  • Managed hosts (WordPress.com, VIP) can't install DMC → can't subprocess → the runtime correctly doesn't exist there. The capability gate and the plugin gate align.
  • agent-call (HTTP webhook) staying in data-machine is correct — any WP can wp_remote_post. CLI exec is fundamentally different.
  • The DMC ↔ wp-coding-agents pairing is canonical and already documented (README "Co-located runtime" driver mode).

Current state

There's no consumer of agents/dispatch-message anywhere in the ecosystem yet. The substrate primitive ships on agents-api main (commit 46e40fb, file src/Channels/register-agents-dispatch-message-ability.php), but nothing claims the filter wp_agent_dispatch_message_handler.

On the Extra Chill VPS the gap is currently filled by /opt/agent-ping-webhook/webhook.py — a standalone Python BaseHTTPServer that nginx proxies, which subprocess.Popens kimaki send. It's broken (payload contract drift: receiver reads prompt, sender writes task) and architecturally outside any plugin. This issue is the proper replacement.

Proposed contract

Channel config

A registry keyed by channel name, populated via apply_filters( 'datamachine_code_cli_channels', [] ) and/or get_option( 'datamachine_code_cli_channels', [] ). Each entry:

```php
'kimaki' => [
'command' => 'kimaki', // absolute path or PATH-resolvable
'args' => [ 'send', '--channel', '{recipient}', '--prompt', '{message}' ],
'detach' => true, // background or wait
'timeout' => 600, // seconds; only when detach=false
'env' => [ /* extra env vars */ ],
'cwd' => null, // optional working dir
],
```

Variables substituted into args:

  • `{recipient}` — `dispatch-message` input `recipient`
  • `{message}` — input `message` (single arg; never interpolated into a shell string)
  • `{conversation_id}` — input `conversation_id`
  • `{channel}` — input `channel` (rarely needed but available)

Substitution is positional argv replacement, not shell interpolation. proc_open is invoked with an array of args, never a string, so no quoting/escaping pitfalls.

Handler registration

```php
\AgentsAPI\AI\Channels\register_dispatch_message_handler(
[ CliChannelTransport::class, 'dispatch' ],
20 // priority — leave room for higher-precedence handlers
);
```

The handler:

  1. Checks `Environment::has_shell()`. If false, returns the original `$handler` (lets others claim it). Logs at debug level.
  2. Looks up `$input['channel']` in the registry. If unknown, returns the original `$handler` (chain continues).
  3. Returns [ self::class, 'execute' ].

`execute()` runs the command, returns canonical output:

```php
[
'sent' => true,
'channel' => $channel,
'recipient' => $recipient,
'message_id' => (string) $pid, // or null for synchronous mode
'metadata' => [ 'exit_code' => ..., 'duration_ms' => ..., 'output' => '...' ],
]
```

On failure, return `WP_Error` (the substrate handles that and fires `agents_dispatch_message_failed`).

Detach vs wait

Two modes:

  • detach=true (default): `proc_open` with `start_new_session`, return immediately with PID as `message_id`. No wait, no exit code. Fits fire-and-forget scheduled flows. Mirrors `webhook.py`'s current behavior.
  • detach=false: `proc_open` with stdout/stderr capture, `proc_close` to get exit code, respect `timeout`. Synchronous, returns full diagnostics in `metadata`.

Security

  • Command is never a user-controlled string. It comes from registered config only.
  • Args are an array; substitution is positional; no shell metacharacters can be injected via `{message}` etc.
  • Environment passed explicitly; no inherited PATH surprises beyond what config specifies.
  • Permission gate uses `agents_dispatch_message_permission` filter (agents-api default `manage_options`). DMC can tighten via that filter if needed.

Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│ agents-api (substrate, no transport knowledge) │
│ agents/dispatch-message contract + handler filter │
└────────────────────▲────────────────────────────────────────────┘
│ register_dispatch_message_handler()

┌────────────────────┴────────────────────────────────────────────┐
│ data-machine-code (THIS issue) │
│ CliChannelTransport — generic subprocess runtime │
│ Gated by Environment::has_shell() │
│ No knowledge of kimaki, cc-connect, telegram, etc. │
└────────────────────▲────────────────────────────────────────────┘
│ writes channel config

┌────────────────────┴────────────────────────────────────────────┐
│ wp-coding-agents (separate issue — bash installer) │
│ bridges/kimaki.sh, cc-connect.sh, telegram.sh │
│ Each installs an mu-plugin or option that registers its │
│ channel→command-template entry. │
└─────────────────────────────────────────────────────────────────┘
```

Proposed file layout

```
inc/Channels/
CliChannelTransport.php # the runtime
CliChannelRegistry.php # config lookup + validation
tests/
smoke-cli-channel-transport.php
```

Acceptance criteria

  • agents/dispatch-message invocations with a registered `channel` are routed to the CLI runtime.
  • When Environment::has_shell() is false, the runtime declines (does not claim the filter).
  • Unknown channel names pass through cleanly so other handlers can claim them.
  • Positional argv substitution (no shell interpolation) is enforced.
  • Detach mode returns immediately with a PID; wait mode captures stdout/stderr/exit_code with timeout.
  • Canonical output schema is returned on success; `WP_Error` on failure.
  • Smoke test demonstrates end-to-end dispatch using a stub command (e.g. `/bin/true` and `/bin/echo`).
  • No knowledge of kimaki, Discord, or any specific runtime anywhere in the code.

Related

  • Substrate: agents-api commit `46e40fb` (agents/dispatch-message + handler filter, v0.107.0)
  • Sibling issue (filed separately): wp-coding-agents — per-bridge config write + legacy /opt/agent-ping-webhook/ retirement
  • Related design space: data-machine #1490 (pluggable agent_call target adapter registry) — adjacent abstraction at the HTTP layer; this issue is the disk-side counterpart living in the correct plugin.

Out of scope

  • Migrating `datamachine/agent-call` to also register as a `dispatch-message` handler for HTTP-channel symmetry. Worth doing eventually, separate issue.
  • HTTP webhook config (already handled by `agent-call`).
  • The legacy /opt/agent-ping-webhook/ retirement (tracked in the wp-coding-agents issue).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions