Skip to content

⏰feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel#95

Open
Rhythmarvin wants to merge 13 commits into
ora-space:mainfrom
Rhythmarvin:mvp/plugin-ipc
Open

⏰feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel#95
Rhythmarvin wants to merge 13 commits into
ora-space:mainfrom
Rhythmarvin:mvp/plugin-ipc

Conversation

@Rhythmarvin

@Rhythmarvin Rhythmarvin commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

从零实现完整的插件 IPC 通信系统,支持 Host (Rust) 与 Plugin (Bun/TS) 之间的双向 JSON-RPC 通信,并扩展支持 Agent 类型插件作为 ACP 协议隧道。

架构

Rust Host (ora-web-server)
  ├── crates/plugin-protocol/   # 5-byte binary frame codec, JSON-RPC validation, lifecycle DTOs
  └── crates/plugin-manager/    # PluginRuntime: spawn, lifecycle, scanner, REST API
        │  stdin/stdout (5-byte frames)
        ▼
Bun Plugin
  └── packages/plugin-sdk/      # definePlugin + runAgentBootstrap
        │  stdin/stdout (NDJSON ACP)          ← Agent 类型插件
        ▼
Actual Agent (OpenCode / Claude Code / ...)

通用插件能力

  • Wire Protocol: 5-byte binary frames [length i32 BE][type i8][payload UTF-8 JSON]
  • JSON-RPC 2.0: Request/Response/Notification,3 种 frame type 严格匹配
  • 完整生命周期: $/initialize → $/activate → Running → $/deactivate → $/exit
  • 双向通信: Plugin 可主动发 Notification 给 Host($/hello
  • 插件 SDK: definePlugin({ activate, deactivate })ExtensionContext with registerHandler()
  • 自定义 handler: ctx.registerHandler("getInfo", ...) 让插件注册自己的 JSON-RPC method
  • 插件扫描器: 扫描 <data_dir>/plugins/ 目录,自动发现含 manifest 的插件
  • Lazy activation: initialize()activate() 拆分为独立步骤

Agent 插件

Agent 插件作为一种特殊的插件类型,作为 Host 与实际 AI Agent(如 OpenCode、Claude Code)之间的薄隧道:北向与 Host 使用 5-byte 帧协议通信,南向通过 ACP 协议与 agent 进程通信。

  • 薄隧道模型: Plugin 不解 ACP 业务语义,只做 5-byte 帧 ↔ NDJSON 帧双向转换
  • PluginEvent tagged union: SessionUpdate / PermissionRequest / Completed / Error,单一 channel 流式推送
  • PluginMetadata enum: 类型化 manifest 解析,不同插件类型携带各自的元数据
  • 后台 reader thread: sync_pending (oneshot) + stream_pending (mpsc) 双通道路由
  • invoke_streaming(): 发请求后立即返回 mpsc::Receiver,不阻塞
  • AgentPeer + runAgentBootstrap(): TS 侧对标 Rust AcpPeer,内置 agent spawn + ACP initialize 握手
  • ACP initialize 并行启动: plugin 在 Host handshake 期间并发完成 agent 连接
  • 健康检查: acp/connect 透传完整 agent capabilities

插件 manifest

一般插件:

{ "ora": { "id": "ora.demo", "kind": "agent", "main": "index.ts" } }

Agent 插件(多 agent 字段声明 CLI 和展示信息):

{ "ora": { "id": "ora.opencode", "kind": "agent", "main": "index.ts",
    "agent": { "cli": "opencode", "displayName": "OpenCode",
               "description": "OpenCode ACP agent" } } }

REST API

Method Path 说明
GET /api/plugins 列出运行中 + 可用插件(含 typed metadata)
POST /api/plugins 按 ID 启动插件
GET /api/plugins/scan 扫描发现的插件列表(含 typed metadata)
POST /api/plugins/{id}/invoke 调用插件方法
POST /api/plugins/{id}/connect Agent 健康检查 + ACP 握手
POST /api/plugins/{id}/forward 同步 ACP 请求转发 (session/new 等)
POST /api/plugins/{id}/prompt 流式 NDJSON 响应 (session/prompt)
POST /api/plugins/{id}/stop 停止插件

手动测试(目前为纯后端,仅支持rest接口测试)

 # 终端1:启动后端
$env:ORA_PORT = 3001; task run:web-backend  
 # 终端2:调用rest接口测试
$Base = "http://localhost:3001"

# 1. 扫描
Invoke-RestMethod "$Base/api/plugins/scan" | ConvertTo-Json -Depth 3

# 2. 启动 OpenCode 插件(ora.opencode或ora.claude)
$start = Invoke-RestMethod -Uri "$Base/api/plugins" -Method Post `
  -Body '{"id":"ora.opencode"}' -ContentType "application/json"
$iid = $start.instanceId

# 3. 健康检查
Invoke-RestMethod -Uri "$Base/api/plugins/$iid/connect" -Method Post `
  -Body '{}' -ContentType "application/json" | ConvertTo-Json -Depth 4

# 4. 创建 ACP session
$cwd = (Get-Location).Path -replace '\\', '/'
$newBody = "{""agentSessionId"":"""",""message"":{""method"":""session/new"",""params"":{""cwd"":""$cwd"",""mcpServers"":[]}}}"
$newSession = Invoke-RestMethod -Uri "$Base/api/plugins/$iid/forward" -Method Post `
  -Body $newBody -ContentType "application/json"
$sid = $newSession.result.response.sessionId

# 5. 发消息 (流式 NDJSON)
$body = "{""agentSessionId"":""$sid"",""text"":""say hello in one sentence""}"
$tmp = "$env:TEMP\curl-body.txt"
[IO.File]::WriteAllText($tmp, $body, [Text.UTF8Encoding]::new($false))
& "D:\Git\mingw64\bin\curl.exe" -s -N -X POST "$Base/api/plugins/$iid/prompt" `
  -H "Content-Type: application/json" -d "@$tmp"; Remove-Item $tmp

# 6. 停止
Invoke-RestMethod -Uri "$Base/api/plugins/$iid/stop" -Method Post

一键测试脚本 (test-rest.sh)

#!/usr/bin/env bash
set -euo pipefail
BASE="${BASE:-http://localhost:3001}"
CWD="${CWD:-$(cd "$(dirname "$0")/.." && pwd | sed 's/\\/\//g')}"

echo "=== Ora Plugin IPC - Agent E2E Test ==="

# ── Scan ─────────────────────────────────────────────────────────
echo ""
echo "-- Scan plugins --"
SCAN=$(curl -s "$BASE/api/plugins/scan")
echo "$SCAN" | python3 -m json.tool 2>/dev/null || echo "$SCAN"

# ── Helper: test one agent plugin ─────────────────────────────────
test_agent() {
  local PLUGIN_ID="$1"
  local LABEL="$2"

  echo ""
  echo "═══════════════════════════════════════════"
  echo "  Testing: $LABEL ($PLUGIN_ID)"
  echo "═══════════════════════════════════════════"

  # Start
  echo ""
  echo "-- Start --"
  START=$(curl -s -X POST "$BASE/api/plugins" \
    -H 'Content-Type: application/json' \
    -d "{\"id\":\"$PLUGIN_ID\"}")
  IID=$(echo "$START" | sed 's/.*"instanceId":"\([^"]*\)".*/\1/')
  echo "instanceId: $IID"

  # Connect
  echo ""
  echo "-- acp/connect --"
  curl -s -X POST "$BASE/api/plugins/$IID/connect" \
    -H 'Content-Type: application/json' -d '{}' | python3 -m json.tool 2>/dev/null || \
    curl -s -X POST "$BASE/api/plugins/$IID/connect" -H 'Content-Type: application/json' -d '{}'

  # session/new
  echo ""
  echo "-- session/new --"
  SESS=$(curl -s -X POST "$BASE/api/plugins/$IID/forward" \
    -H 'Content-Type: application/json' \
    -d "{\"agentSessionId\":\"\",\"message\":{\"method\":\"session/new\",\"params\":{\"cwd\":\"$CWD\",\"mcpServers\":[]}}}" \
    | sed 's/.*"sessionId":"\([^"]*\)".*/\1/')
  echo "agentSessionId: $SESS"

  # Prompt (streaming)
  echo ""
  echo "-- session/prompt (streaming) --"
  curl -s -N -X POST "$BASE/api/plugins/$IID/prompt" \
    -H 'Content-Type: application/json' \
    -d "{\"agentSessionId\":\"$SESS\",\"text\":\"say hello in one sentence\"}" | while IFS= read -r line; do
    [ -z "$line" ] && continue
    type=$(echo "$line" | sed 's/.*"type":"\([^"]*\)".*/\1/' | head -1)
    case "$type" in
      data)
        inner=$(echo "$line" | sed 's/.*"type":"\([^"]*\)","stopReason.*/\1/' | sed 's/.*"type":"\([^"]*\)","update.*/\1/')
        echo "  <- ${inner:-data}"
        ;;
      error) echo "  ERR: $line" ;;
      end)   echo "  -- stream end --" ;;
      *)     echo "  $line" ;;
    esac
  done

  # Stop
  echo ""
  echo "-- Stop --"
  curl -s -X POST "$BASE/api/plugins/$IID/stop"
  echo ""
}

# ── Test OpenCode ─────────────────────────────────────────────────
if echo "$SCAN" | grep -q '"id":"ora.opencode"'; then
  test_agent "ora.opencode" "OpenCode"
else
  echo ""
  echo "  SKIP: ora.opencode not found in scan results"
fi

# ── Test Claude Code ──────────────────────────────────────────────
if echo "$SCAN" | grep -q '"id":"ora.claude"'; then
  test_agent "ora.claude" "Claude Code"
else
  echo ""
  echo "  SKIP: ora.claude not found in scan results"
fi

echo ""
echo "=== Test Complete ==="
image

Complete redesign based on full_design.md VS Code-like bidirectional model.

New crates:
- ora-plugin-protocol: 5-byte binary frame codec, JSON-RPC envelope validation,
  lifecycle DTOs ($/initialize handshake). 12 unit tests.
- ora-plugin-manager: PluginRuntime with Bun process spawn, handshake,
  typed invoke/stop. Uses tokio async I/O.

Rewritten packages:
- @ora-space/plugin-sdk v0.2.0: definePlugin API, ExtensionContext,
  internal transport/bootstrap/rpc. Single-package architecture.

HTTP API (apps/web/server):
- POST /api/plugins        start plugin from path
- POST /api/plugins/{id}/invoke  send JSON-RPC request
- POST /api/plugins/{id}/stop    graceful shutdown
- GET  /api/plugins              list running plugins

Wire format: [length i32 BE][type i8][payload UTF-8 JSON]
Frame types: 1=Request, 2=Response, 3=Notification
- Move examples/demo-plugin/index.ts (was packages/plugin-sdk/examples/)
- Remove 'examples' from plugin-sdk package.json files list
- Keep plugin-sdk as a clean library package
- Demo plugin uses definePlugin() to demonstrate SDK API
- Add registerHandler(method, handler) to ExtensionContext
- Bootstrap loads plugin config after handshake, calls activate(ctx)
- Demo plugin registers 'getInfo' handler returning demo string
- Demo example tests full flow: ping + getInfo + notification + shutdown
- Switch to std::process (blocking) for stable plugin process I/O
- Dispatcher supports runtime handler registration from plugins
Lifecycle phases:
  1. $/initialize → session handshake (echo identity)
  2. $/activate → plugin activate(ctx) + register handlers
  3. === Running === (business requests: ping, getInfo, etc.)
  4. $/deactivate → plugin deactivate() + LIFO dispose subscriptions
  5. $/exit → graceful process.exit(0)

Bootstrap state machine: awaitingInitialize → awaitingActivate → running → deactivating → exiting
Rust: spawn_and_activate() sends both $/initialize + $/activate
Rust: shutdown() sends $/deactivate + $/exit
- [host] → invoke / ← response logs in PluginProcess
- [host] starting plugin / plugin started logs in PluginRuntime
- [host] $/deactivate acknowledged / plugin exited logs in shutdown
- Remove unused bootstrap_path field from PluginRuntime
The old plugin-runtime was never tracked by git (was in .gitignore).
Its function is now merged into plugin-sdk. Delete the directory
entirely instead of hiding it behind .gitignore.
- PluginProcess::spawn() — only $/initialize handshake
- PluginProcess::activate() — $/activate, can be called lazily
- PluginRuntime::initialize() + activate() — separate public API
- PluginRuntime::start() — convenience method, calls both
- PluginManagerConfig: derives managed paths from data_dir (plugins_dir, etc.)
- scanner: scans <data_dir>/plugins/ for subdirectories with valid package.json + ora manifest
- PluginRuntime::scan() returns discovered plugins
- PluginRuntime::start_by_id() starts a plugin by its scan ID
- REST: GET /api/plugins now shows running + available plugins
- REST: POST /api/plugins accepts {"id": "ora.demo-plugin"}
- REST: GET /api/plugins/scan lists discovered plugins
- 2 unit tests for scanner
@Rhythmarvin Rhythmarvin changed the title Mvp/plugin ipc feat: plugin IPC system MVP — bidirectional Host↔Plugin communication Jul 21, 2026
- PluginEvent tagged union + PluginMetadata enum for typed discovery
- Background reader thread with sync/stream dual pending maps
- invoke_streaming() returns mpsc channel for long-running operations
- Scanner parses ora.agent manifest block
- runAgentBootstrap + AgentPeer: NDJSON <-> 5-byte frame tunnel
- ACP initialize handshake with concurrent startup
- REST: connect, forward, prompt (streaming NDJSON) endpoints
- E2E verified with OpenCode v1.18.4
@Rhythmarvin Rhythmarvin changed the title feat: plugin IPC system MVP — bidirectional Host↔Plugin communication feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel Jul 23, 2026
@Rhythmarvin

Rhythmarvin commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

opencode-plugin.zip
claude-plugin.zip
opencode-plugin、claude-plugin解压到.data/plugins目录下
test-rest.sh
git bash中使用: bash test-rest.sh

@EricWvi EricWvi changed the title feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel [wip] feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel Jul 23, 2026
@EricWvi EricWvi changed the title [wip] feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel ⏰ feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel Jul 23, 2026
@Rhythmarvin Rhythmarvin changed the title ⏰ feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel Jul 23, 2026
@EricWvi EricWvi changed the title feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel ⏰feat: plugin IPC system — bidirectional Host<->Plugin + Agent ACP tunnel Jul 23, 2026
@Rhythmarvin
Rhythmarvin force-pushed the mvp/plugin-ipc branch 2 times, most recently from b8f9417 to 64a3b16 Compare July 24, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant