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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions