feat: implement MCP proxy for OpenClaw tool access in contractor agent

Complete the MCP tool call chain:
- contractor-agent bridge exposes /mcp/execute endpoint for tool callbacks
- openclaw-mcp-server.mjs proxies OpenClaw tool defs to Claude as MCP tools
- sdk-adapter passes --mcp-config on first turn with all OpenClaw tools
- tool-test plugin registers contractor_echo in globalThis tool handler map
- agent-config-writer auto-sets tools.profile=full so OpenClaw sends tool defs
- Fix --mcp-config arg ordering: prompt must come before <configs...> flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-11 13:05:03 +01:00
parent f7c5875eeb
commit 76a7931f97
24 changed files with 12935 additions and 256 deletions

View File

@@ -372,6 +372,25 @@ export type ContractorBridgeModelRequest = {
Exact fields depend on how OpenClaw provider/model requests are exposed in runtime.
## Implementation notes from probe testing
See BRIDGE_MODEL_FINDINGS.md for full details. Key constraints that affect implementation:
- The sidecar MUST support SSE streaming (`stream: true` always sent by OpenClaw). Non-streaming
responses cause OpenClaw to drop assistant history from subsequent turns.
- OpenClaw sends the full system prompt (~28K chars) rebuilt on every turn. The input filter
must strip this and extract only the latest user message.
- User message format from OpenClaw: `[Day YYYY-MM-DD HH:MM TZ] message text`
- Claude session continuation uses UUID from `@anthropic-ai/claude-agent-sdk` `message.session_id`.
Resume via `options: { resume: sessionId }`.
- Custom model is registered via `openclaw.json` provider config, not a plugin SDK call.
The install script must write the provider entry and set `baseUrl` to the sidecar port.
## Revised milestone order
Milestone 3 (bridge model) is moved before Milestone 2 (CLI) because the bridge sidecar is the
load-bearing component. Everything else depends on confirming it works end-to-end.
## Suggested implementation order
## Milestone 1, project skeleton

View File

@@ -0,0 +1,452 @@
# OpenClaw 插件开发经验教训
> 记录插件开发过程中踩过的坑,供后续迭代参考。最初源自 Dirigent后续经验来自 Yonexus。
---
## 1. OpenClaw 热重载与模块状态
**问题**OpenClaw 每次热重载hot-reload会把插件模块放入新的 VM 隔离上下文,模块级变量全部重置。
```typescript
// ❌ 错误:热重载后 Map 被清空turn 状态丢失
const channelStates = new Map<string, ChannelTurnState>();
```
**解法**:把需要跨热重载持久化的状态挂在 `globalThis` 上。
```typescript
// ✅ 正确globalThis 绑定在 Node.js 进程层面,热重载不影响
function channelStates(): Map<string, ChannelTurnState> {
if (!(_G._tmChannelStates instanceof Map))
_G._tmChannelStates = new Map();
return _G._tmChannelStates as Map<string, ChannelTurnState>;
}
```
**规则**
- 业务状态turn state、speaker list、pending turns`globalThis`
- 热重载内部的临时变量局部锁、dedup set`globalThis`(理由同上)
- 无状态工具函数 → 普通模块变量即可
---
## 2. Hook 事件重复触发Event Deduplication
**问题**OpenClaw 热重载会把新的 handler 叠加在旧的 handler 上,同一事件(如 `agent_end``before_model_resolve`)被多个 handler 实例处理,导致:
- Turn 被推进两次
- Speaker 被重复 suppress
- Schedule trigger 重复发送
**解法**:用挂在 `globalThis` 上的 `WeakSet`(事件对象)或 `Set`runId做去重。
```typescript
// before_model_resolve事件对象去重WeakSet 自动 GC
const processed = new WeakSet<object>();
api.on("before_model_resolve", async (event) => {
if (processed.has(event as object)) return;
processed.add(event as object);
// ...
});
// agent_endrunId 去重Set + 上限淘汰)
const processedRunIds = new Set<string>();
api.on("agent_end", async (event) => {
const runId = (event as any).runId;
if (processedRunIds.has(runId)) return;
processedRunIds.add(runId);
if (processedRunIds.size > 500) {
processedRunIds.delete(processedRunIds.values().next().value);
}
// ...
});
```
**规则**:所有 hook handler 必须有去重逻辑dedup 结构本身也要挂在 `globalThis`
---
## 3. Gateway 生命周期事件与 Agent 会话事件的区别
**问题**`gateway_start` / `gateway_stop` 是全局事件,只触发一次。但 `register()` 每次热重载都会被调用,导致 `gateway_start` handler 被重复注册sidecar 被重复启动。
**解法**:用 `globalThis` flag 保证只注册一次。
```typescript
const _G = globalThis as Record<string, unknown>;
const LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered";
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;
startSideCar(...);
api.on("gateway_stop", () => stopSideCar(...));
}
```
**规则**
- `gateway_start` / `gateway_stop` handler → `globalThis` flag 保护
- `before_model_resolve` / `agent_end` / `message_received` → 每次 `register()` 都注册,但靠 event dedup 防止重复处理
---
## 4. ChannelStore 文件缓存陷阱
**问题**`ChannelStore` 懒加载文件(第一次读后设 `loaded=true` 不再重读)。如果在 gateway 运行期间直接编辑 `dirigent-channels.json`,已存在的 `ChannelStore` 实例不会感知变化,`getMode()` 对新增 channel 返回 `"none"`,导致 turn management 完全失效before_model_resolve 看到 `mode === "none"` 直接 return不做任何 suppress
**现象**:新 channel 里所有 agent 同时响应,日志里没有任何 `before_model_resolve` 的 suppressing 或 anchor set 日志。
**解法(当前)**:编辑 `dirigent-channels.json` 后必须 `openclaw gateway restart`
**更好的长期方案**`ChannelStore` 应该在 `setMode()`/`setLockedMode()` 时通知所有实例,或改用 `fs.watch()` 监听文件变化,或每次 `getMode()` 都从文件读(对 read 频率低的场景可以接受)。
---
## 5. Discord 权限 Overwrite 的 type 字段
**问题**:设置 channel permission overwrite 时,`type` 字段含义:
- `type: 0` → 针对 **role**(角色)
- `type: 1` → 针对 **member**(成员/用户)
将 bot 用户 ID 作为 member overwrite 时必须用 `type: 1`,用 `type: 0` 会返回错误或静默失败Discord 会把 ID 当 role 处理)。
```typescript
// ✅ 正确
{ id: botUserId, type: 1, allow: "68608", deny: "0" }
```
**常用 permission bitmask**
- VIEW_CHANNEL = 1024 (1 << 10)
- SEND_MESSAGES = 2048 (1 << 11)
- READ_MESSAGE_HISTORY = 65536 (1 << 16)
- 三者合计 = 68608
---
## 6. AgentTool 的 execute API非 handler
**问题**OpenClaw Plugin SDK 要求 tool 使用 `execute: async (toolCallId, params) => {}` 接口,不是 `handler:`。如果需要 `ctx.agentId`,要使用工厂函数形式。
```typescript
// ✅ 正确
api.registerTool({
name: "my-tool",
// ...schema...
execute: async (toolCallId, params) => {
// toolCallId 是 stringparams 是入参对象
return { result: "ok" };
},
});
// ✅ 需要 agentId 时
api.registerTool((ctx) => ({
name: "my-tool",
execute: async (toolCallId, params) => {
const agentId = ctx.agentId;
// ...
},
}));
```
---
## 7. Sidecar 锁文件防重复启动
**问题**gateway 重启或热重载时 `startSideCar()` 可能被多次调用,导致多个 sidecar 进程竞争同一端口。
**解法**:写 lock 文件(`/tmp/dirigent-sidecar.lock`),启动前检查文件是否存在且对应进程仍在运行。
```typescript
const lockFile = "/tmp/dirigent-sidecar.lock";
if (fs.existsSync(lockFile)) {
const pid = Number(fs.readFileSync(lockFile, "utf8").trim());
if (isProcessAlive(pid)) {
logger.info("sidecar already running, skipping");
return;
}
}
// 启动 sidecar写 lock file
```
---
## 8. 并发 advanceSpeaker 竞争
**问题**:两个 VM 上下文的 `agent_end` handler 可能同时执行,两者都通过了 runId 去重runId 不同),都调用 `advanceSpeaker`,导致 speaker index 被推进两次。
**解法**:在 `advanceSpeaker` 入口加 per-channel 锁(`Set<string>` 挂在 `globalThis`)。
```typescript
if (advancingChannels.has(channelId)) return; // 已有并发调用,跳过
advancingChannels.add(channelId);
try {
await advanceSpeaker(...);
} finally {
advancingChannels.delete(channelId);
}
```
---
## 9. isTurnPending 的生命周期边界
**问题**`clearTurnPending` 的位置影响正确性:
- 太早(在 `advanceSpeaker` 前清除)→ 下一个 wakeup 可能被误判为合法 turn在 cycle boundary 期间 index 尚未更新导致 speaker 错误
- 太晚无问题,但在 `pollForTailMatch` 期间必须保持 `isTurnPending=true`,否则 re-trigger 会被当作合法 turn 重入
**正确位置**`advanceSpeaker` 完成后、`triggerNextSpeaker` 前。
---
## 10. Discord Gateway 重连后的消息丢失
**问题**Gateway 重启后bot 重新连接 Discord WS 有延迟1030s。如果在 bot 完全连接前就发送 schedule trigger`<@bot_id>➡️`bot 会错过该消息WS 不推送历史消息)。
**现象**:发送了 triggerchannel 里能看到消息,但 bot 没有响应。
**解法**
1. Gateway 重启后等待所有 bot 的 `discord client initialized` 日志出现再发种子消息
2. 或手动补发 trigger
**长期方案**sidecar 可以暴露一个 `/status` 接口,等待所有 Discord 账号连接就绪后再允许外部发消息。
---
## 11. 连接型插件的热重载陷阱Yonexus
**问题**Yonexus.Client / Yonexus.Server 是"连接型插件"——插件本身管理一条持久 WebSocket 连接(或监听端口)。如果用模块级变量做启动防重复保护:
```typescript
// ❌ 错误:热重载后新 VM 上下文重置_started = false → 第二个 runtime 被创建
let _started = false;
export function createPlugin(api) {
if (_started) return;
_started = true;
const runtime = createRuntime(...);
runtime.start();
}
```
热重载后:
- **服务端**:第二个 runtime 尝试 bind 同一端口 → EADDRINUSE → `runtime.start()` 抛出 → 被 `.catch` 静默吞掉,但 `globalThis.__yonexusServer` 已被覆盖为指向新的未启动的transport → `sendRule()` 永远返回 false
- **客户端**:第二个 runtime 成功建立了新的 WebSocket 连接,与旧连接并存,产生重复认证
**解法**
```typescript
// ✅ 正确:用 globalThis 保护,热重载后新 VM 上下文也能看到 flag
const _G = globalThis as Record<string, unknown>;
const STARTED_KEY = "_yonexusClientStarted";
export function createPlugin(api) {
if (_G[STARTED_KEY]) {
// 热重载时更新 __yonexusClient 指向仍在运行的旧 runtime存在 globalThis 上)
// 无需重新启动
return;
}
_G[STARTED_KEY] = true;
// ... 创建并启动 runtime
}
```
如果需要让热重载后新注册的 hook/rule 生效,还需把 `ruleRegistry``onXxxCallbacks` 等也存到 `globalThis`,而不是在函数体内每次新建。
**规则**
- 任何管理持久连接/监听端口的插件,其启动 flag 必须放 `globalThis`
- 相关的 registry、回调数组也应放 `globalThis`,否则热重载后 `__pluginId` API 对象被覆盖,旧 runtime 的回调数组失去引用
---
## 12. WebSocket 服务端 Transport 的消息路由竞态Yonexus
**问题**Server transport 在 `ws.on("message")` 里通过 identifier 查 `_connections` 得到 `ClientConnection`
```typescript
// ❌ 危险:当 ws_new 还在 tempConnections但 _connections["test-client"] 指向即将关闭的 ws_old 时
const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn;
```
**场景**
1. `ws_old`(外部测试脚本)已认证,`_connections["test-client"] = ws_old`
2. `ws_new`(插件重连)发 hello → 进入 tempConnectionsassignedIdentifier = "test-client"
3. 插件发 `auth_request` → message handler 查 `_connections.get("test-client")` → 返回 ws_old
4. `promoteToAuthenticated("test-client", ws_old)` → ws_old 不在 tempConnections → 返回 false
5. `onClientAuthenticated` 仍然触发 → `_connections.get("test-client")` = ws_old已关闭`sendRule` 返回 false
**解法**:消息路由时,如果发送方 `ws` 仍在 `tempConnections`,直接用 `tempConn`(持有正确 ws 引用的本地对象),**不再** fallback 到 `_connections`
```typescript
// ✅ 正确:按 ws 引用路由,不按 identifier 路由
if (this.tempConnections.has(ws)) {
this.options.onMessage(tempConn, message);
return;
}
// ws 已 promote从 _connections 中找
let connection = tempConn;
for (const [, conn] of this._connections) {
if (conn.ws === ws) { connection = conn; break; }
}
this.options.onMessage(connection, message);
```
**附加修复**`promoteToAuthenticated` 的返回值不应被忽略。只有 promote 成功时才触发 `onClientAuthenticated`
```typescript
const promoted = transport.promoteToAuthenticated(identifier, connection.ws);
if (promoted) {
options.onClientAuthenticated?.(identifier);
}
```
**规则**WebSocket 服务端的消息路由应始终以**发送方的 ws 对象引用**为准,不以 identifier 查映射表。identifier 可能在 tempConnections 和 _connections 之间的过渡期产生歧义。
---
## 13. 服务端 Session 竞态 → 客户端 re-hello 恢复Yonexus
**问题**:服务端在已认证连接关闭时(`onDisconnect`)删除对应的 session。如果另一个客户端连接同 identifier`auth_request` 恰好在 session 被删除之后到达,服务端返回 `auth_failed("not_paired")`,即使客户端持有有效 secret。
**场景**
1. 测试脚本 ws_1 已认证 → session["test-client"] 存在
2. 插件 ws_2 发送 hello → session["test-client"] 被覆写socket = ws_2
3. 测试脚本 ws_1 关闭 → `handleDisconnect("test-client")``sessions.delete("test-client")`
4. 插件 ws_2 发 `auth_request` → session 不存在 → `auth_failed("not_paired")`
5. 插件有 secret`auth_required` 状态没有 re-hello 逻辑 → 永远卡住
**解法**:客户端收到 `auth_failed("not_paired")` 且持有有效 secret 时,重新发送 hello 以在服务端创建新 session然后重试认证
```typescript
if (payload.reason === "not_paired" && hasClientSecret(this.clientState)) {
this.sendHello(); // 重建 session触发 hello_ack("auth_required") → sendAuthRequest()
return;
}
```
**规则**:客户端凡是遇到"自己有凭据但服务端找不到 session"的错误,都应尝试重走 hello 流程,而不是直接进入 `auth_required` 等待用户干预。
---
## 14. ContractorAgent 开发经验
### 14.1 `openclaw.json` runtime.type 只允许 "embedded" 或 "acp"
**问题**:写入自定义 `runtime.type: "contractor"``openclaw.json` 会导致 gateway 启动时 schema 校验失败:
```
agents.list.N.runtime: Invalid input (allowed: "embedded", "acp")
```
**解法**:用 `model` 字段识别 contractor agentmodel 为 `"contractor-agent/contractor-claude-bridge"`),不写 custom runtime type。
---
### 14.2 `openclaw gateway restart` 不一定真正重启
**问题**`openclaw gateway restart` 命令输出 "Restarted systemd service" 但有时不实际重启PID 不变)。这会导致部署新插件代码后仍然运行旧版本。
**解法**:用 `systemctl --user restart openclaw-gateway.service` 替代。可用 `ss -tlnp | grep <port>``journalctl` 确认 PID 是否变化。
---
### 14.3 `claude -p --output-format stream-json` 需要 `--verbose`
**问题**`claude -p --output-format stream-json` 没有 `--verbose` 时报错:
```
Error: When using --print, --output-format=stream-json requires --verbose
```
**解法**:始终加 `--verbose` 标志。
---
### 14.4 async plugin register 的问题
**问题**OpenClaw 不等待 `register()` 返回的 promise
```
[plugins] plugin register returned a promise; async registration is ignored (plugin=contractor-agent)
```
实际上,`register()``await` 之后的代码(如 `isPortFree` check + `createBridgeServer`)仍然异步执行,但 OpenClaw 不等待它们完成。Bridge server 的 `listen` 回调是异步的,所以 `[contractor-bridge] sidecar listening` 日志出现在 `[contractor-agent] plugin registered` 之后——这是正常的。关键是不要把 CLI 注册(`registerCli`)放在 `await` 后面,否则可能因为竞态而失效。
**规则**:把 `registerCli(api)` 和其他同步注册调用移到 `await` 之前,或确认它们在 async 流程中最终被执行。
---
### 14.5 `ctx.program.command()` vs 导入 Commander版本冲突
**问题**:在插件里 `import { Command } from "commander"` 后用 `ctx.program.addCommand(new Command(...))` 会导致:
```
TypeError: subCommand._prepareForParse is not a function
```
原因:插件的 `commander` 包和 OpenClaw 内部的 `commander` 包版本不同,`Command` 实例不兼容。
**解法**:只用 `ctx.program.command("subcommand")` 链式调用,不导入 Commander不创建 `new Command()`。依赖列表也不需要 `commander`
---
### 14.6 Node.js `Readable` push 模式的 async iteration 问题
**问题**:用 `new Readable({ read() {} })` 作为 claude 输出的行缓冲中介,然后 `for await (const raw of lines)` 读取——在 gateway 环境中可靠性存疑(本地测试失败,可能是 push 时序或 Buffer 编码问题)。
**解法**:用 `readline.createInterface({ input: child.stdout })` + 事件驱动的 Promise 队列。`readline` 的行分割行为经过 Node.js 官方测试,比手动 `split("\n")` + Readable 更可靠。
---
### 14.7 `--mcp-config <configs...>` 是变参——不能把 prompt 放在它后面
**问题**`claude -p ... --mcp-config /tmp/mcp.json "You are operating..."` 会把 prompt 当作第二个 MCP config 文件,报错:
```
Error: Invalid MCP configuration:
MCP config file not found: /tmp/workspace/You are operating as a contractor agent...
```
原因:`--mcp-config` 接受多个值(`<configs...>`),贪婪消费后续所有非 flag 参数,把 prompt 当成了第二个路径。
**解法**:把 prompt 放在 `-p` 的紧接下一个位置(所有 flag 之前),`--mcp-config` 放在最后:
```typescript
const args = [
"-p",
prompt, // ← 紧跟 -p在所有 flag 之前
"--output-format", "stream-json",
"--verbose",
...
"--mcp-config", mcpConfigPath, // ← 放在最后,不影响 prompt
];
```
---
### 14.8 OpenClaw 不会自动向 agent 发送工具列表——需要配置 `tools.profile`
**问题**contractor-e2e agent 的请求 body 里 `tools: []`——OpenClaw 没有把 `contractor_echo` 等工具发给 bridge导致 MCP proxy 无工具可暴露。
原因OpenClaw 只对配置了工具 profile 的 agent 才在请求中携带工具定义。
**解法**:在 `openclaw.json` 里给 contractor agent 加 `"tools": { "profile": "full" }`。在代码里,`markAgentAsClaudeContractor()` 负责在创建时自动写入这个字段。
---
### 14.9 OpenClaw 工具执行MCP proxy 的"全局注册表"模式
**问题**bridge 运行在 OpenClaw gateway 进程内,但 OpenClaw plugin SDK 没有暴露 `callTool(name, args)` API。要从 bridge 的 `/mcp/execute` 端点调用其他插件注册的工具,没有官方途径。
**解法**:用 `globalThis` 共享注册表:
```typescript
// 在 tool-test/index.ts或任意插件
const _G = globalThis as Record<string, unknown>;
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
}
(_G["_contractorToolHandlers"] as Map<..., ...>).set("contractor_echo", async (args) => {
return `[contractor_echo] ${(args as any).message}`;
});
```
bridge 的 `/mcp/execute` 端点从 `globalThis._contractorToolHandlers` 查找并调用处理函数。
**限制**:工具实现需要主动"双注册"OpenClaw 的 `api.registerTool()` + globalThis 注册表)。不支持远程工具(运行在其他进程的工具)。完整方案待后续研究。

View File

@@ -2,279 +2,52 @@
## Project Goal
Build an OpenClaw integration that turns contractor coding agents such as Claude Code into OpenClaw-managed agents.
Build an OpenClaw plugin that turns Claude Code into an OpenClaw-managed contractor agent.
Phase 1 focuses on Claude Code only. Gemini support is deferred.
Phase 1: Claude only. Gemini deferred.
## Product Direction
The goal is not merely to launch Claude Code from OpenClaw, but to make Claude Code behave like an OpenClaw-managed agent while preserving Claude Code's own model capability and execution workflow.
OpenClaw manages agent identity, routing, workspace, and session ownership.
Claude Code manages the live conversational and task context for its own session.
The plugin bridges the two without duplicating context management.
Desired properties:
- OpenClaw manages agent identity, routing, workspace, and session ownership
- Claude Code manages the live conversational and task context for its own session
- OpenClaw tools and skills are adapted into forms Claude Code can consume
- OpenClaw channels and sessions remain the outer control plane
## Core Design
### Session Proxy Runtime
Use a session proxy runtime rather than replaying full OpenClaw-managed context into Claude Code on every turn.
Important implementation note: in OpenClaw this should be delivered as a **custom model**.
Reason:
- OpenClaw expects each agent to have a primary model
- for this special contractor agent, the real primary intelligence is provided by Claude Code
- therefore the most natural integration seam is a custom model that accepts OpenClaw messages, routes them into the mapped Claude Code session, and then injects Claude's response back into OpenClaw session state
So although the architecture is conceptually a session proxy runtime, the concrete integration form inside OpenClaw should be a **bridge model** or **pseudo-model backend** for Claude-backed agents.
Core idea:
- map each OpenClaw session to a Claude Code session
- on each new user message, find the mapped Claude session
- resume or continue that Claude session
- forward only the newest actionable message plus minimal metadata
- avoid rebuilding full prompt history on every turn
- return the Claude response through the custom model seam so it becomes part of normal OpenClaw agent flow
This reduces:
- duplicated context management
- token waste
- divergence between OpenClaw-visible history and Claude's actual working memory
### Control Plane Split
OpenClaw remains source of truth for:
- agent registration
- routing and bindings
- workspace ownership
- permissions and approvals
- channel transport
- tool hosting
Claude Code remains source of truth for:
- live conversation state within a contractor session
- reasoning and coding flow
- internal execution trajectory
## Feasibility Notes from Docs
### OpenClaw ACP Agents
OpenClaw already supports Claude Code as an ACP harness runtime.
Relevant doc statements:
> "ACP sessions let OpenClaw run external coding harnesses (for example ... Claude Code ...) through an ACP backend plugin."
> "For Claude Code through ACP, the stack is:
> 1. OpenClaw ACP session control plane
> 2. bundled `acpx` runtime plugin
> 3. Claude ACP adapter
> 4. Claude-side runtime/session machinery"
This indicates Claude Code session hosting is already available as a foundation.
Source:
- https://docs.openclaw.ai/tools/acp-agents
### OpenClaw ACP Session Semantics
Relevant doc statements:
> "Follow-up messages in that conversation route to the same ACP session."
> "`/new` and `/reset` reset the same bound ACP session in place."
> "Use `resumeSessionId` to continue a previous ACP session instead of starting fresh."
These features support a persistent contractor-session bridge.
Source:
- https://docs.openclaw.ai/tools/acp-agents
### OpenClaw Plugin Extensibility
Relevant doc statements:
> "Plugins extend OpenClaw with new capabilities: channels, model providers, ... agent tools, or any combination."
> "Agent tools | `api.registerTool(...)`"
> "For provider plugins, tool plugins, hook plugins, and anything that is not a messaging channel."
These suggest a native OpenClaw plugin can host the contractor-agent bridge logic.
Sources:
- https://docs.openclaw.ai/plugins/building-plugins
- https://docs.openclaw.ai/plugins/sdk-entrypoints
### OpenClaw CLI Extension Surface
Plugins can register CLI commands, but current docs do not show support for patching an existing core command such as `openclaw agents add` with extra flags.
Relevant doc statements:
> "Common registration methods: ... `registerCommand` / `registerCli` | CLI commands"
> "For plugin-owned root CLI commands, prefer `api.registerCli(..., { descriptors: [...] })` ..."
Therefore the safer design is to add a plugin-owned command root rather than extending `openclaw agents add` directly.
Sources:
- https://docs.openclaw.ai/tools/plugin
- https://docs.openclaw.ai/plugins/sdk-entrypoints
### Claude Code Plugin Surface
Claude Code has a plugin model with skills, agents, hooks, MCP servers, and LSP servers.
Relevant doc statements:
> "A plugin is a self-contained directory of components that extends Claude Code with custom functionality. Plugin components include skills, agents, hooks, MCP servers, and LSP servers."
> "Plugins add skills to Claude Code ... Claude can invoke them automatically based on task context"
This supports the idea that OpenClaw skills and tooling may be adapted into Claude Code-friendly forms instead of being injected as raw prompt only.
Source:
- https://code.claude.com/docs/en/plugins-reference
## Bridge Model Direction
The contractor-agent implementation should expose one or more plugin-provided custom model ids for OpenClaw agents to use as their primary model.
For Claude phase 1, the working assumption is a model id in the shape of:
- `contractor-claude-bridge`
Behavior of this bridge model:
1. receive the agent turn from OpenClaw
2. identify the current OpenClaw session key and target contractor agent
3. resolve or create the mapped Claude Code session
4. filter OpenClaw-managed envelope/context so only the latest actionable message and minimal metadata are forwarded
5. send the turn to Claude Code through the chosen ACP/session continuation path
6. capture Claude's response
7. inject the response back into OpenClaw through the model response path and session file flow
This approach fits OpenClaw's requirement that every agent has a primary model while still allowing Claude Code to act as the true reasoning engine for contractor-backed agents.
## Initial CLI Plan
Introduce a plugin-owned command:
```bash
openclaw contractor-agents add --agent-id <agent-id> --workspace <workspace> --contractor <claude|gemini>
```
For phase 1, only `claude` is supported.
### Intended behavior
For `--contractor claude`, the command should:
1. call:
```bash
openclaw agents add <agent-id> --workspace <workspace> --model <bridge-model-id> --non-interactive
```
2. mark the created agent as a contractor-backed agent
3. persist contractor runtime metadata for that agent
4. prepare storage for OpenClaw-session to Claude-session mappings
## Proposed Agent Config Shape
Exact schema TBD, but directionally:
```json5
{
agents: {
list: [
{
id: "my-agent",
workspace: "/path/to/workspace",
model: "contractor-claude-bridge",
runtime: {
type: "contractor",
contractor: {
kind: "claude",
backend: "acp",
mode: "persistent"
}
}
}
]
}
}
```
## Session Mapping Store
Keep dynamic mappings out of main config.
Suggested runtime state file location:
```text
<agent-workspace>/.openclaw/contractor-agent/session-map.json
```
Possible structure:
```json
{
"sessions": [
{
"openclawSessionKey": "agent:my-agent:main",
"claudeSessionId": "<resumeSessionId-or-runtime-id>",
"createdAt": "2026-04-10T00:00:00Z",
"lastActivityAt": "2026-04-10T00:00:00Z",
"state": "active"
}
]
}
```
See ARCHITECTURE.md for component design.
See IMPLEMENTATION.md for file structure and build order.
See BRIDGE_MODEL_FINDINGS.md for probe test results that confirm the design.
## Phase 1 Scope
### In scope
- Claude contractor agent creation flow
- plugin-owned CLI for contractor agent management
- bridge model placeholder for Claude-backed agents
- persistent mapping of OpenClaw sessions to Claude sessions
- initial Claude-only runtime design
- planning documents and repository structure
- Claude contractor agent provisioning via plugin-owned CLI
- `contractor-claude-bridge` model: OpenAI-compatible sidecar that proxies OpenClaw turns into Claude Code sessions
- Session map: persistent mapping of OpenClaw session keys to Claude session UUIDs
- Input filter: strip OpenClaw system prompt, extract latest user message, inject one-time bootstrap
- SSE streaming: bridge sidecar must stream Claude output back as SSE
- Install script: writes provider config to `openclaw.json`, registers plugin
### Out of scope for now
- Gemini implementation
- full OpenClaw skill auto-conversion
- complete tool surface bridging
- advanced recovery and replay semantics
- Full OpenClaw tool bridging to Claude
- Advanced recovery and replay semantics
- UI polish and operational tooling
## Open Questions
1. What is the best implementation seam for the bridge model inside OpenClaw, provider plugin, tool plugin, or service-backed pseudo model?
2. How should contractor metadata be persisted, agent runtime stanza, plugin config, or both?
3. What is the canonical identifier for Claude session continuation, ACP session id, adapter resume id, or plugin-owned alias?
4. Which minimal OpenClaw tools should be exposed first to Claude?
5. How should reset/new map to Claude-side session recreation?
1. ~~What is the implementation seam for the bridge model?~~ **Resolved**`openclaw.json` provider entry pointing to a local OpenAI-compatible sidecar. No plugin SDK model registration API. See BRIDGE_MODEL_FINDINGS.md.
2. How should contractor metadata be persisted agent runtime stanza, plugin config, or both?
3. ~~What is the canonical identifier for Claude session continuation?~~ **Resolved** — UUID returned as `message.session_id` from the Claude Agent SDK. Resume via `options.resume`. See BRIDGE_MODEL_FINDINGS.md.
4. Which minimal OpenClaw tools should be exposed first to Claude? (deferred to later)
5. How should `/reset` and `/new` map to Claude-side session recreation?
## Immediate Next Steps
## Next Steps
1. Design the `openclaw contractor-agents add` CLI surface in detail
2. Define config schema for contractor-backed agents
3. Define session-map file schema and lifecycle
4. Determine how the bridge model id is registered by the plugin
5. Draft implementation skeleton for Claude-only support
1. ~~Verify bridge model registration mechanism~~ Done
2. ~~Verify Claude session continuation identifier~~ Done
3. **Build bridge sidecar** with SSE streaming and Claude SDK dispatch
4. Implement input filter and session map store
5. Add bootstrap injection and recovery
6. Implement CLI (`openclaw contractor-agents add`)
7. Write install script

28
openclaw.plugin.json Normal file
View File

@@ -0,0 +1,28 @@
{
"id": "contractor-agent",
"name": "Contractor Agent",
"version": "0.1.0",
"description": "Turns Claude Code into an OpenClaw-managed contractor agent",
"main": "src/index.ts",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"bridgePort": {
"type": "number",
"default": 18800,
"description": "Port for the contractor bridge sidecar HTTP server"
},
"bridgeApiKey": {
"type": "string",
"default": "contractor-bridge-local",
"description": "API key for the bridge sidecar (local use only)"
},
"permissionMode": {
"type": "string",
"default": "bypassPermissions",
"description": "Claude Code permission mode for contractor sessions"
}
}
}
}

10971
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "contractor-agent",
"version": "0.1.0",
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
"type": "module",
"main": "src/index.ts",
"scripts": {
"check": "tsc --noEmit",
"install-plugin": "node scripts/install.mjs --install",
"uninstall-plugin": "node scripts/install.mjs --uninstall"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.101"
},
"devDependencies": {
"typescript": "^5.0.0",
"openclaw": "*"
}
}

189
scripts/install.mjs Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* Install / uninstall the contractor-agent plugin into OpenClaw.
*
* Usage:
* node scripts/install.mjs --install
* node scripts/install.mjs --uninstall
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "..");
const PLUGIN_ID = "contractor-agent";
const PLUGIN_INSTALL_DIR = path.join(os.homedir(), ".openclaw", "plugins", PLUGIN_ID);
const OPENCLAW_CONFIG = path.join(os.homedir(), ".openclaw", "openclaw.json");
const BRIDGE_PORT = 18800;
const BRIDGE_API_KEY = "contractor-bridge-local";
// ── Helpers ───────────────────────────────────────────────────────────────────
function readConfig() {
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, "utf8"));
}
function writeConfig(cfg) {
fs.writeFileSync(OPENCLAW_CONFIG, JSON.stringify(cfg, null, 2) + "\n", "utf8");
}
function setIfMissing(obj, key, value) {
if (obj[key] === undefined || obj[key] === null) obj[key] = value;
}
// ── Install ───────────────────────────────────────────────────────────────────
function install() {
console.log(`[install] Installing ${PLUGIN_ID}...`);
// 1. Copy plugin files to ~/.openclaw/plugins/contractor-agent/
if (fs.existsSync(PLUGIN_INSTALL_DIR)) {
fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true });
}
fs.mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true });
// OpenClaw expects the plugin entry (index.ts) at the plugin root,
// matching the convention used by existing plugins like dirigent.
// We flatten src/ to the plugin root and copy supporting files alongside it.
// Flatten src/ → plugin root
const srcDir = path.join(PROJECT_ROOT, "src");
fs.cpSync(srcDir, PLUGIN_INSTALL_DIR, { recursive: true });
// Copy package.json (update main to point to index.ts at root)
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8"));
pkg.main = "index.ts";
fs.writeFileSync(
path.join(PLUGIN_INSTALL_DIR, "package.json"),
JSON.stringify(pkg, null, 2) + "\n",
);
// Copy openclaw.plugin.json — set main to root index.ts
const manifest = JSON.parse(
fs.readFileSync(path.join(PROJECT_ROOT, "openclaw.plugin.json"), "utf8"),
);
manifest.main = "index.ts";
fs.writeFileSync(
path.join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
// 2. Install npm dependencies inside the plugin dir
console.log(`[install] Installing npm dependencies...`);
execSync("npm install --omit=dev --no-audit --no-fund", {
cwd: PLUGIN_INSTALL_DIR,
stdio: "inherit",
});
// 3. Update openclaw.json
const cfg = readConfig();
// Add provider
cfg.models = cfg.models ?? {};
cfg.models.providers = cfg.models.providers ?? {};
cfg.models.providers[PLUGIN_ID] = {
baseUrl: `http://127.0.0.1:${BRIDGE_PORT}/v1`,
apiKey: BRIDGE_API_KEY,
api: "openai-completions",
models: [
{
id: "contractor-claude-bridge",
name: "Contractor Claude Bridge",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 16000,
},
],
};
// Add to plugin allow list
cfg.plugins = cfg.plugins ?? {};
cfg.plugins.allow = cfg.plugins.allow ?? [];
if (!cfg.plugins.allow.includes(PLUGIN_ID)) {
cfg.plugins.allow.push(PLUGIN_ID);
}
// Add load path
cfg.plugins.load = cfg.plugins.load ?? {};
cfg.plugins.load.paths = cfg.plugins.load.paths ?? [];
if (!cfg.plugins.load.paths.includes(PLUGIN_INSTALL_DIR)) {
cfg.plugins.load.paths.push(PLUGIN_INSTALL_DIR);
}
// Add plugin entry
cfg.plugins.entries = cfg.plugins.entries ?? {};
cfg.plugins.entries[PLUGIN_ID] = cfg.plugins.entries[PLUGIN_ID] ?? {};
cfg.plugins.entries[PLUGIN_ID].enabled = true;
// Set default config — setIfMissing so user values are preserved
const pluginCfg = cfg.plugins.entries[PLUGIN_ID].config ?? {};
setIfMissing(pluginCfg, "bridgePort", BRIDGE_PORT);
setIfMissing(pluginCfg, "bridgeApiKey", BRIDGE_API_KEY);
setIfMissing(pluginCfg, "permissionMode", "bypassPermissions");
cfg.plugins.entries[PLUGIN_ID].config = pluginCfg;
writeConfig(cfg);
console.log(`[install] Done. Restart the gateway to activate:`);
console.log(` openclaw gateway restart`);
console.log(` openclaw contractor-agents add --agent-id <id> --workspace <path> --contractor claude`);
}
// ── Uninstall ─────────────────────────────────────────────────────────────────
function uninstall() {
console.log(`[uninstall] Removing ${PLUGIN_ID}...`);
const cfg = readConfig();
// Remove provider
if (cfg.models?.providers?.[PLUGIN_ID]) {
delete cfg.models.providers[PLUGIN_ID];
}
// Remove from allow list
if (Array.isArray(cfg.plugins?.allow)) {
cfg.plugins.allow = cfg.plugins.allow.filter((id) => id !== PLUGIN_ID);
}
// Remove load path
if (Array.isArray(cfg.plugins?.load?.paths)) {
cfg.plugins.load.paths = cfg.plugins.load.paths.filter(
(p) => p !== PLUGIN_INSTALL_DIR,
);
}
// Remove plugin entry
if (cfg.plugins?.entries?.[PLUGIN_ID]) {
delete cfg.plugins.entries[PLUGIN_ID];
}
writeConfig(cfg);
// Remove installed files
if (fs.existsSync(PLUGIN_INSTALL_DIR)) {
fs.rmSync(PLUGIN_INSTALL_DIR, { recursive: true });
console.log(`[uninstall] Removed ${PLUGIN_INSTALL_DIR}`);
}
console.log(`[uninstall] Done. Restart the gateway:`);
console.log(` openclaw gateway restart`);
}
// ── Main ──────────────────────────────────────────────────────────────────────
const arg = process.argv[2];
if (arg === "--install") {
install();
} else if (arg === "--uninstall") {
uninstall();
} else {
console.error("Usage: node scripts/install.mjs --install | --uninstall");
process.exit(1);
}

49
src/bridge/bootstrap.ts Normal file
View File

@@ -0,0 +1,49 @@
export type BootstrapInput = {
agentId: string;
openclawSessionKey: string;
workspace: string;
/** Skills XML block extracted from the OpenClaw system prompt, if any */
skillsBlock?: string;
};
/**
* Build the one-time bootstrap message injected at the start of a new Claude session.
* This tells Claude it is operating as an OpenClaw contractor agent.
* Must NOT be re-injected on every turn.
*/
export function buildBootstrap(input: BootstrapInput): string {
const lines = [
`You are operating as a contractor agent inside OpenClaw.`,
``,
`## Context`,
`- Agent ID: ${input.agentId}`,
`- Session key: ${input.openclawSessionKey}`,
`- Workspace: ${input.workspace}`,
``,
`## Role`,
`You receive tasks from OpenClaw users and complete them using your tools.`,
`You do not need to manage your own session context — OpenClaw handles session routing.`,
`Your responses go directly back to the user through OpenClaw.`,
``,
`## Guidelines`,
`- Work in the specified workspace directory.`,
`- Be concise and action-oriented. Use tools to accomplish tasks rather than describing what you would do.`,
`- Each message you receive contains the latest user request. Previous context is in your session memory.`,
`- If a task is unclear, ask one focused clarifying question.`,
];
if (input.skillsBlock) {
lines.push(
``,
`## Skills`,
`The following skills are available. When a task matches a skill's description:`,
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
``,
input.skillsBlock,
);
}
return lines.join("\n");
}

View File

@@ -0,0 +1,75 @@
import type { BridgeInboundRequest, OpenAIMessage } from "../types/model.js";
function messageText(m: OpenAIMessage): string {
if (typeof m.content === "string") return m.content;
return m.content
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("");
}
/**
* Extract the latest user message from the OpenClaw request.
*
* OpenClaw accumulates all user messages and sends the full array every turn,
* but assistant messages may be missing if the previous response wasn't streamed
* correctly. The bridge model only needs the latest user message to forward to
* Claude — Claude maintains its own session context.
*
* OpenClaw prefixes user messages with a timestamp: "[Day YYYY-MM-DD HH:MM TZ] text"
* We strip the timestamp prefix before forwarding.
*/
export function extractLatestUserMessage(req: BridgeInboundRequest): string {
const userMessages = req.messages.filter((m) => m.role === "user");
if (userMessages.length === 0) return "";
const raw = messageText(userMessages[userMessages.length - 1]);
// Strip OpenClaw timestamp prefix: "[Sat 2026-04-11 08:32 GMT+1] "
return raw.replace(/^\[[^\]]+\]\s*/, "").trim();
}
export type RequestContext = {
agentId: string;
workspace: string;
/** Raw <available_skills>...</available_skills> XML block from the system prompt */
skillsBlock: string;
};
/**
* Parse agent ID and workspace path from the OpenClaw system prompt.
*
* OpenClaw does NOT send agent ID / session key as HTTP headers — it's embedded
* in the system prompt as a "## Runtime" line:
* Runtime: agent=contractor-e2e | host=... | repo=/tmp/contractor-e2e-workspace | ...
*
* We parse this line to extract `agent` (agent ID) and `repo` (workspace path).
*/
export function extractRequestContext(req: BridgeInboundRequest): RequestContext {
const systemMsg = req.messages.find((m) => m.role === "system");
if (!systemMsg) return { agentId: "", workspace: "", skillsBlock: "" };
const text = messageText(systemMsg);
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "" };
const runtimeLine = runtimeMatch[1];
const agentMatch = runtimeLine.match(/\bagent=([^|\s]+)/);
const repoMatch = runtimeLine.match(/\brepo=([^|\s]+)/);
// Extract <available_skills>...</available_skills> XML block.
// Expand leading "~/" in <location> paths to the actual home dir so Claude doesn't
// try /root/.openclaw/... (which fails with EACCES).
const skillsMatch = text.match(/<available_skills>[\s\S]*?<\/available_skills>/);
const home = process.env.HOME ?? "/root";
const skillsBlock = skillsMatch
? skillsMatch[0].replace(/~\//g, `${home}/`)
: "";
return {
agentId: agentMatch?.[1] ?? "",
workspace: repoMatch?.[1] ?? "",
skillsBlock,
};
}

293
src/bridge/server.ts Normal file
View File

@@ -0,0 +1,293 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import type { BridgeInboundRequest } from "../types/model.js";
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
import { buildBootstrap } from "./bootstrap.js";
import { dispatchToClaude } from "../claude/sdk-adapter.js";
import {
getSession,
putSession,
markOrphaned,
updateActivity,
} from "../contractor/session-map-store.js";
export type BridgeServerConfig = {
port: number;
apiKey: string;
permissionMode: string;
/** Fallback: resolve workspace from agent id if not parseable from system prompt */
resolveAgent?: (
agentId: string,
sessionKey: string,
) => { workspace: string } | null;
/**
* globalThis key that holds a Map<string, (args: unknown) => Promise<string>>.
* Plugins register their tool implementations here so the MCP proxy can call them.
*/
toolRegistryKey?: string;
logger: { info: (msg: string) => void; warn: (msg: string) => void };
};
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(json),
});
res.end(json);
}
/** Write a single SSE data line. */
function sseWrite(res: http.ServerResponse, data: string): void {
res.write(`data: ${data}\n\n`);
}
/** Build an OpenAI streaming chunk for a text delta. */
function buildChunk(id: string, text: string): string {
return JSON.stringify({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "contractor-claude-bridge",
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
});
}
/** Build the final stop chunk. */
function buildStopChunk(id: string): string {
return JSON.stringify({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "contractor-claude-bridge",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
}
function parseBodyRaw(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString("utf8");
if (body.length > 4_000_000) req.destroy(new Error("body too large"));
});
req.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
req.on("error", reject);
});
}
function parseBody(req: http.IncomingMessage): Promise<BridgeInboundRequest> {
return parseBodyRaw(req) as Promise<BridgeInboundRequest>;
}
export function createBridgeServer(config: BridgeServerConfig): http.Server {
const { port, apiKey, permissionMode, resolveAgent, toolRegistryKey = "_contractorToolHandlers", logger } = config;
async function handleChatCompletions(
req: http.IncomingMessage,
res: http.ServerResponse,
): Promise<void> {
let body: BridgeInboundRequest;
try {
body = await parseBody(req);
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
// Extract agent ID and workspace from the system prompt's Runtime line.
// OpenClaw does NOT send agent/session info as HTTP headers — it's in the system prompt.
const { agentId: parsedAgentId, workspace: parsedWorkspace, skillsBlock } = extractRequestContext(body);
const latestMessage = extractLatestUserMessage(body);
if (!latestMessage) {
sendJson(res, 400, { error: "no user message found" });
return;
}
// Use agentId as session key — one persistent Claude session per agent (v1).
const agentId = parsedAgentId;
const sessionKey = agentId; // stable per-agent key
logger.info(
`[contractor-bridge] turn agentId=${agentId} workspace=${parsedWorkspace} msg=${latestMessage.substring(0, 80)}`,
);
// Resolve workspace: prefer what we parsed from the system prompt (most accurate);
// fall back to openclaw.json lookup for validation.
let workspace = parsedWorkspace;
if (!workspace && agentId) {
const agentMeta = resolveAgent?.(agentId, sessionKey);
if (agentMeta) workspace = agentMeta.workspace;
}
if (!workspace) {
logger.warn(`[contractor-bridge] could not resolve workspace agentId=${agentId}`);
workspace = "/tmp";
}
// Look up existing Claude session
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
let claudeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
// Build prompt: bootstrap on first turn, bare message on subsequent turns
const isFirstTurn = !claudeSessionId;
const prompt = isFirstTurn
? `${buildBootstrap({ agentId, openclawSessionKey: sessionKey, workspace, skillsBlock: skillsBlock || undefined })}\n\n---\n\n${latestMessage}`
: latestMessage;
// Start SSE response
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Transfer-Encoding": "chunked",
});
const completionId = `chatcmpl-bridge-${randomUUID().slice(0, 8)}`;
let newSessionId = "";
let hasError = false;
const openclawTools = body.tools ?? [];
try {
for await (const event of dispatchToClaude({
prompt,
workspace,
resumeSessionId: claudeSessionId ?? undefined,
permissionMode,
openclawTools: isFirstTurn ? openclawTools : undefined,
bridgePort: port,
bridgeApiKey: apiKey,
})) {
if (event.type === "text") {
sseWrite(res, buildChunk(completionId, event.text));
} else if (event.type === "done") {
newSessionId = event.sessionId;
} else if (event.type === "error") {
logger.warn(`[contractor-bridge] claude error: ${event.message}`);
hasError = true;
sseWrite(res, buildChunk(completionId, `[contractor-bridge error: ${event.message}]`));
}
}
} catch (err) {
logger.warn(`[contractor-bridge] dispatch error: ${String(err)}`);
hasError = true;
sseWrite(res, buildChunk(completionId, `[contractor-bridge dispatch failed: ${String(err)}]`));
}
sseWrite(res, buildStopChunk(completionId));
sseWrite(res, "[DONE]");
res.end();
// Persist session mapping
if (newSessionId && sessionKey && !hasError) {
const now = new Date().toISOString();
putSession(workspace, {
openclawSessionKey: sessionKey,
agentId,
contractor: "claude",
claudeSessionId: newSessionId,
workspace,
createdAt: existingEntry?.createdAt ?? now,
lastActivityAt: now,
state: "active",
});
logger.info(
`[contractor-bridge] session mapped sessionKey=${sessionKey} claudeSessionId=${newSessionId}`,
);
} else if (hasError && sessionKey && existingEntry) {
markOrphaned(workspace, sessionKey);
}
}
const server = http.createServer(async (req, res) => {
const url = req.url ?? "/";
const method = req.method ?? "GET";
// Auth check
if (apiKey) {
const auth = req.headers.authorization ?? "";
if (auth !== `Bearer ${apiKey}`) {
sendJson(res, 401, { error: "unauthorized" });
return;
}
}
if (method === "GET" && url === "/health") {
sendJson(res, 200, { ok: true, service: "contractor-bridge" });
return;
}
if (method === "GET" && url === "/v1/models") {
sendJson(res, 200, {
object: "list",
data: [
{
id: "contractor-claude-bridge",
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "contractor-agent",
},
],
});
return;
}
if (method === "POST" && url === "/v1/chat/completions") {
try {
await handleChatCompletions(req, res);
} catch (err) {
logger.warn(`[contractor-bridge] unhandled error: ${String(err)}`);
if (!res.headersSent) {
sendJson(res, 500, { error: "internal server error" });
}
}
return;
}
// MCP proxy tool execution callback.
// Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
if (method === "POST" && url === "/mcp/execute") {
let body: { tool?: string; args?: Record<string, unknown> };
try {
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown> };
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
const toolName = body.tool ?? "";
const toolArgs = body.args ?? {};
logger.info(`[contractor-bridge] mcp/execute tool=${toolName}`);
const registry = (globalThis as Record<string, unknown>)[toolRegistryKey];
const handler = registry instanceof Map ? (registry as Map<string, (args: unknown) => Promise<string>>).get(toolName) : undefined;
if (!handler) {
sendJson(res, 200, { error: `Tool '${toolName}' not found in contractor tool registry` });
return;
}
try {
const result = await handler(toolArgs);
sendJson(res, 200, { result });
} catch (err) {
sendJson(res, 200, { error: `Tool execution failed: ${String(err)}` });
}
return;
}
sendJson(res, 404, { error: "not_found" });
});
server.listen(port, "127.0.0.1", () => {
logger.info(`[contractor-bridge] sidecar listening on 127.0.0.1:${port}`);
});
return server;
}

217
src/claude/sdk-adapter.ts Normal file
View File

@@ -0,0 +1,217 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
export type ClaudeMessage =
| { type: "text"; text: string }
| { type: "done"; sessionId: string }
| { type: "error"; message: string };
export type OpenAITool = {
type: "function";
function: { name: string; description?: string; parameters?: unknown };
};
export type ClaudeDispatchOptions = {
prompt: string;
workspace: string;
resumeSessionId?: string;
permissionMode?: string;
/** OpenClaw tool definitions to expose to Claude as MCP tools */
openclawTools?: OpenAITool[];
/** Bridge port for MCP proxy callbacks */
bridgePort?: number;
/** Bridge API key for MCP proxy callbacks */
bridgeApiKey?: string;
};
// Resolve the MCP server script path relative to this file.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../mcp/openclaw-mcp-server.mjs");
/**
* Write OpenClaw tool definitions to a temp file and create an --mcp-config JSON
* so Claude Code can call them as `mcp__openclaw__<toolname>` tools.
*
* Returns the path to the mcp-config JSON file, or null if setup fails.
*/
function setupMcpConfig(
tools: OpenAITool[],
bridgePort: number,
bridgeApiKey: string,
): string | null {
if (!tools.length) return null;
if (!fs.existsSync(MCP_SERVER_SCRIPT)) return null;
try {
const tmpDir = os.tmpdir();
const sessionId = `oc-${Date.now()}`;
const toolDefsPath = path.join(tmpDir, `${sessionId}-tools.json`);
const mcpConfigPath = path.join(tmpDir, `${sessionId}-mcp.json`);
fs.writeFileSync(toolDefsPath, JSON.stringify(tools, null, 2), "utf8");
const mcpConfig = {
mcpServers: {
openclaw: {
command: process.execPath,
args: [MCP_SERVER_SCRIPT],
env: {
TOOL_DEFS_FILE: toolDefsPath,
BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`,
BRIDGE_API_KEY: bridgeApiKey,
},
},
},
};
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), "utf8");
return mcpConfigPath;
} catch {
return null;
}
}
/**
* Dispatch a turn to Claude Code using `claude -p --output-format stream-json --verbose`.
* Returns an async iterable of ClaudeMessage events.
*/
export async function* dispatchToClaude(
opts: ClaudeDispatchOptions,
): AsyncIterable<ClaudeMessage> {
const {
prompt,
workspace,
resumeSessionId,
permissionMode = "bypassPermissions",
openclawTools,
bridgePort = 18800,
bridgeApiKey = "",
} = opts;
// NOTE: put prompt right after -p, before --mcp-config.
// --mcp-config takes <configs...> (multiple values) and would greedily
// consume the prompt if it came after --mcp-config.
const args: string[] = [
"-p",
prompt,
"--output-format", "stream-json",
"--verbose",
"--permission-mode", permissionMode,
"--dangerously-skip-permissions",
];
if (resumeSessionId) {
args.push("--resume", resumeSessionId);
}
// If OpenClaw tools provided, set up MCP proxy so Claude sees them.
// Added after prompt so --mcp-config <configs...> doesn't consume the prompt.
let mcpConfigPath: string | null = null;
if (openclawTools?.length && !resumeSessionId) {
// Only inject MCP config on first turn — resume already has MCP from the session.
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey);
if (mcpConfigPath) {
args.push("--mcp-config", mcpConfigPath);
}
}
const child = spawn("claude", args, {
cwd: workspace,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
const stderrLines: string[] = [];
child.stderr?.on("data", (chunk: Buffer) => {
stderrLines.push(chunk.toString("utf8").trim());
});
const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
let capturedSessionId = "";
const events: ClaudeMessage[] = [];
let done = false;
let resolveNext: (() => void) | null = null;
rl.on("line", (line: string) => {
if (!line.trim()) return;
let event: Record<string, unknown>;
try {
event = JSON.parse(line);
} catch {
return;
}
const type = event.type as string;
if (type === "assistant") {
const msg = event.message as { content?: Array<{ type: string; text?: string }> };
for (const block of msg?.content ?? []) {
if (block.type === "text" && block.text) {
events.push({ type: "text", text: block.text });
}
}
}
if (type === "result") {
const sessionId = (event.session_id as string) ?? "";
if (sessionId) capturedSessionId = sessionId;
}
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r();
}
});
rl.on("close", () => {
done = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r();
}
});
while (true) {
if (events.length > 0) {
yield events.shift()!;
continue;
}
if (done) break;
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
while (events.length > 0) {
yield events.shift()!;
}
await new Promise<void>((resolve) => {
child.on("close", resolve);
if (child.exitCode !== null) resolve();
});
// Clean up temp files
if (mcpConfigPath) {
try { fs.unlinkSync(mcpConfigPath); } catch { /* ignore */ }
// tool defs file path is embedded in the config — leave it for now
}
if (capturedSessionId) {
yield { type: "done", sessionId: capturedSessionId };
} else {
const stderrSummary = stderrLines.join(" ").slice(0, 200);
yield {
type: "error",
message: `claude did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`,
};
}
}

View File

@@ -0,0 +1,48 @@
import fs from "node:fs";
import { createBaseAgent } from "../openclaw/agents-add-runner.js";
import { markAgentAsClaudeContractor } from "../openclaw/agent-config-writer.js";
import { ensureContractorStateDir } from "../contractor/runtime-state.js";
import { initEmptySessionMap } from "../contractor/session-map-store.js";
export type AddArgs = {
agentId: string;
workspace: string;
contractor: string;
};
export async function runContractorAgentsAdd(args: AddArgs): Promise<void> {
const { agentId, workspace, contractor } = args;
// Validate
if (!agentId) throw new Error("--agent-id is required");
if (!workspace) throw new Error("--workspace is required");
if (!contractor) throw new Error("--contractor is required");
if (contractor !== "claude") {
throw new Error(`--contractor ${contractor}: only 'claude' is supported in phase 1`);
}
// Ensure workspace exists
if (!fs.existsSync(workspace)) {
fs.mkdirSync(workspace, { recursive: true });
}
console.log(`[contractor-agent] Creating base OpenClaw agent: ${agentId}`);
createBaseAgent({
agentId,
workspace,
bridgeModel: "contractor-agent/contractor-claude-bridge",
});
console.log(`[contractor-agent] Writing contractor metadata`);
markAgentAsClaudeContractor(agentId, workspace);
console.log(`[contractor-agent] Initializing runtime state`);
ensureContractorStateDir(workspace);
initEmptySessionMap(workspace);
console.log(`[contractor-agent] Done.`);
console.log(` Agent: ${agentId}`);
console.log(` Workspace: ${workspace}`);
console.log(` Model: contractor-agent/contractor-claude-bridge`);
console.log(` State dir: ${workspace}/.openclaw/contractor-agent/`);
}

50
src/cli/register-cli.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { runContractorAgentsAdd } from "./contractor-agents-add.js";
export function registerCli(api: OpenClawPluginApi): void {
api.registerCli(
(ctx) => {
// Use ctx.program.command() directly — do NOT import Commander separately.
// Importing a different Commander version causes _prepareForParse failures.
const contractorAgents = ctx.program.command("contractor-agents")
.description("Manage Claude-backed contractor agents");
contractorAgents
.command("add")
.description("Provision a new Claude-backed contractor agent")
.requiredOption("--agent-id <id>", "Agent id")
.requiredOption("--workspace <path>", "Workspace directory")
.requiredOption("--contractor <kind>", "Contractor kind (claude)")
.action(async (opts: { agentId: string; workspace: string; contractor: string }) => {
try {
await runContractorAgentsAdd({
agentId: opts.agentId,
workspace: opts.workspace,
contractor: opts.contractor,
});
} catch (err) {
console.error(`[contractor-agents add] Error: ${String(err)}`);
process.exitCode = 1;
}
});
contractorAgents
.command("status")
.description("Show status of a contractor agent (not yet implemented)")
.requiredOption("--agent-id <id>", "Agent id")
.action(() => {
console.log("[contractor-agents status] not yet implemented");
});
},
{
commands: ["contractor-agents"],
descriptors: [
{
name: "contractor-agents",
description: "Manage Claude-backed contractor agents",
hasSubcommands: true,
},
],
},
);
}

View File

@@ -0,0 +1,33 @@
import type { ContractorAgentMetadata } from "../types/contractor.js";
const CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
type AgentConfig = {
id: string;
workspace?: string;
model?: string;
};
/**
* Determine whether an agent is a contractor-backed Claude agent and return its metadata.
* Contractor agents are identified by their model string — we do NOT use a custom
* runtime.type because OpenClaw's schema only allows "embedded" or "acp".
* Returns null if the agent is not contractor-backed.
*/
export function resolveContractorAgentMetadata(
agentConfig: AgentConfig,
permissionMode: string,
): ContractorAgentMetadata | null {
if (agentConfig.model !== CONTRACTOR_MODEL) return null;
const workspace = agentConfig.workspace;
if (!workspace) return null;
return {
agentId: agentConfig.id,
contractor: "claude",
bridgeModel: "contractor-claude-bridge",
workspace,
permissionMode,
};
}

View File

@@ -0,0 +1,15 @@
import path from "node:path";
import fs from "node:fs";
export function getContractorStateDir(workspace: string): string {
return path.join(workspace, ".openclaw", "contractor-agent");
}
export function getSessionMapPath(workspace: string): string {
return path.join(getContractorStateDir(workspace), "session-map.json");
}
export function ensureContractorStateDir(workspace: string): void {
const dir = getContractorStateDir(workspace);
fs.mkdirSync(dir, { recursive: true });
}

View File

@@ -0,0 +1,68 @@
import fs from "node:fs";
import { getSessionMapPath, ensureContractorStateDir } from "./runtime-state.js";
import type { SessionMapEntry, SessionMapFile } from "../types/session-map.js";
function readFile(workspace: string): SessionMapFile {
const p = getSessionMapPath(workspace);
if (!fs.existsSync(p)) {
return { version: 1, sessions: [] };
}
try {
return JSON.parse(fs.readFileSync(p, "utf8")) as SessionMapFile;
} catch {
return { version: 1, sessions: [] };
}
}
function writeFile(workspace: string, data: SessionMapFile): void {
ensureContractorStateDir(workspace);
fs.writeFileSync(getSessionMapPath(workspace), JSON.stringify(data, null, 2), "utf8");
}
export function getSession(workspace: string, openclawSessionKey: string): SessionMapEntry | null {
const data = readFile(workspace);
return data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey) ?? null;
}
export function putSession(workspace: string, entry: SessionMapEntry): void {
const data = readFile(workspace);
const idx = data.sessions.findIndex((s) => s.openclawSessionKey === entry.openclawSessionKey);
if (idx >= 0) {
data.sessions[idx] = entry;
} else {
data.sessions.push(entry);
}
writeFile(workspace, data);
}
export function updateActivity(workspace: string, openclawSessionKey: string): void {
const data = readFile(workspace);
const entry = data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey);
if (entry) {
entry.lastActivityAt = new Date().toISOString();
writeFile(workspace, data);
}
}
export function markOrphaned(workspace: string, openclawSessionKey: string): void {
const data = readFile(workspace);
const entry = data.sessions.find((s) => s.openclawSessionKey === openclawSessionKey);
if (entry) {
entry.state = "orphaned";
writeFile(workspace, data);
}
}
export function removeSession(workspace: string, openclawSessionKey: string): void {
const data = readFile(workspace);
data.sessions = data.sessions.filter((s) => s.openclawSessionKey !== openclawSessionKey);
writeFile(workspace, data);
}
export function initEmptySessionMap(workspace: string): void {
ensureContractorStateDir(workspace);
const p = getSessionMapPath(workspace);
if (!fs.existsSync(p)) {
writeFile(workspace, { version: 1, sessions: [] });
}
}

95
src/index.ts Normal file
View File

@@ -0,0 +1,95 @@
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { normalizePluginConfig } from "./types/contractor.js";
import { resolveContractorAgentMetadata } from "./contractor/metadata-resolver.js";
import { createBridgeServer } from "./bridge/server.js";
import { registerCli } from "./cli/register-cli.js";
import type http from "node:http";
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const tester = net.createServer();
tester.once("error", () => resolve(false));
tester.once("listening", () => tester.close(() => resolve(true)));
tester.listen(port, "127.0.0.1");
});
}
// ── GlobalThis state ─────────────────────────────────────────────────────────
// All persistent state lives on globalThis to survive OpenClaw hot-reloads.
// See LESSONS_LEARNED.md items 1, 3, 11.
const _G = globalThis as Record<string, unknown>;
const LIFECYCLE_KEY = "_contractorAgentLifecycleRegistered";
const SERVER_KEY = "_contractorAgentBridgeServer";
// ── Plugin entry ─────────────────────────────────────────────────────────────
export default {
id: "contractor-agent",
name: "Contractor Agent",
async register(api: OpenClawPluginApi) {
const config = normalizePluginConfig(api.pluginConfig);
// Resolve agent metadata for the bridge server's resolveAgent callback.
// We do this by reading openclaw.json — the bridge server calls this on every turn.
function resolveAgent(agentId: string, _sessionKey: string) {
try {
const configPath = path.join(
(process.env.HOME ?? "/root"),
".openclaw",
"openclaw.json",
);
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
agents?: { list?: Array<{ id: string; workspace?: string; model?: string }> };
};
const agent = raw.agents?.list?.find((a) => a.id === agentId);
if (!agent) return null;
const meta = resolveContractorAgentMetadata(agent, config.permissionMode);
if (!meta) return null;
return { workspace: meta.workspace };
} catch {
return null;
}
}
// ── Gateway lifecycle (start bridge server once per gateway process) ──────
// Guard with globalThis flag AND a port probe to handle the case where the
// gateway is already running the server while a CLI subprocess is starting up.
// (See LESSONS_LEARNED.md item 7 — lock file / port probe pattern)
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;
// Only bind if port is not already in use (avoids EADDRINUSE in CLI mode)
const portFree = await isPortFree(config.bridgePort);
if (!portFree) {
api.logger.info(
`[contractor-agent] bridge already running on port ${config.bridgePort}, skipping bind`,
);
return;
}
const server = createBridgeServer({
port: config.bridgePort,
apiKey: config.bridgeApiKey,
permissionMode: config.permissionMode,
resolveAgent,
logger: api.logger,
});
_G[SERVER_KEY] = server;
api.on("gateway_stop", () => {
const s = _G[SERVER_KEY] as http.Server | undefined;
if (s) s.close();
api.logger.info("[contractor-agent] bridge server stopped");
});
}
// ── CLI ───────────────────────────────────────────────────────────────────
registerCli(api);
api.logger.info(`[contractor-agent] plugin registered (bridge port: ${config.bridgePort})`);
},
};

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* OpenClaw MCP proxy server (stdio transport).
*
* Reads OpenClaw tool definitions from a JSON file (TOOL_DEFS_FILE env var),
* exposes them to Claude Code as MCP tools, and executes them by calling back
* to the bridge's /mcp/execute HTTP endpoint (BRIDGE_EXECUTE_URL env var).
*
* Started per-session by sdk-adapter.ts via --mcp-config.
*/
import fs from "node:fs";
import readline from "node:readline";
// ── Load tool definitions ─────────────────────────────────────────────────────
function loadToolDefs() {
const path = process.env.TOOL_DEFS_FILE;
if (!path) return [];
try {
return JSON.parse(fs.readFileSync(path, "utf8"));
} catch {
return [];
}
}
// ── MCP stdio transport (newline-delimited JSON-RPC 2.0) ──────────────────────
const rl = readline.createInterface({ input: process.stdin, terminal: false });
function send(msg) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
function sendResult(id, result) {
send({ jsonrpc: "2.0", id, result });
}
function sendError(id, code, message) {
send({ jsonrpc: "2.0", id, error: { code, message } });
}
// ── Tool execution via bridge HTTP ────────────────────────────────────────────
async function executeTool(name, args) {
const url = process.env.BRIDGE_EXECUTE_URL;
const apiKey = process.env.BRIDGE_API_KEY ?? "";
if (!url) return `[mcp-proxy] BRIDGE_EXECUTE_URL not configured`;
try {
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ tool: name, args }),
});
const data = await resp.json();
if (data.error) return `[tool error] ${data.error}`;
return data.result ?? "(no result)";
} catch (err) {
return `[mcp-proxy fetch error] ${String(err)}`;
}
}
// ── Request dispatcher ────────────────────────────────────────────────────────
let toolDefs = [];
async function handleRequest(msg) {
const id = msg.id ?? null;
const method = msg.method;
const params = msg.params ?? {};
if (method === "initialize") {
toolDefs = loadToolDefs();
sendResult(id, {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "openclaw-mcp-proxy", version: "0.1.0" },
});
return;
}
if (method === "notifications/initialized") {
return; // no response for notifications
}
if (method === "tools/list") {
const tools = toolDefs.map((t) => ({
name: t.function.name,
description: t.function.description ?? "",
inputSchema: t.function.parameters ?? { type: "object", properties: {} },
}));
sendResult(id, { tools });
return;
}
if (method === "tools/call") {
const toolName = params.name;
const toolArgs = params.arguments ?? {};
try {
const result = await executeTool(toolName, toolArgs);
sendResult(id, {
content: [{ type: "text", text: result }],
isError: false,
});
} catch (err) {
sendResult(id, {
content: [{ type: "text", text: `[mcp-proxy] ${String(err)}` }],
isError: true,
});
}
return;
}
sendError(id, -32601, `Method not found: ${method}`);
}
// ── Main loop ─────────────────────────────────────────────────────────────────
rl.on("line", async (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let msg;
try {
msg = JSON.parse(trimmed);
} catch {
sendError(null, -32700, "Parse error");
return;
}
await handleRequest(msg);
});

View File

@@ -0,0 +1,49 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
type OpenClawConfig = Record<string, unknown>;
type AgentEntry = Record<string, unknown>;
const CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
function readConfig(): { config: OpenClawConfig; configPath: string } {
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
const raw = fs.readFileSync(configPath, "utf8");
return { config: JSON.parse(raw) as OpenClawConfig, configPath };
}
function writeConfig(configPath: string, config: OpenClawConfig): void {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
}
/**
* Verify that an agent is configured with the contractor bridge model.
* Called after createBaseAgent() — the model is already set by `openclaw agents add`,
* so this is just a sanity check that throws if something went wrong.
*
* We intentionally do NOT write a custom `runtime.type` — OpenClaw's schema only
* allows "embedded" or "acp", and contractor agents are identified by their model.
*/
export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void {
const { config, configPath } = readConfig();
const agents = (config.agents as { list?: AgentEntry[] } | undefined)?.list;
if (!agents) throw new Error("agents.list not found in openclaw.json");
const agent = agents.find((a) => a.id === agentId);
if (!agent) throw new Error(`agent ${agentId} not found in openclaw.json`);
if (agent.model !== CONTRACTOR_MODEL) {
throw new Error(
`agent ${agentId} model is "${String(agent.model)}", expected "${CONTRACTOR_MODEL}"`,
);
}
// Ensure tools are enabled for this agent so OpenClaw includes tool definitions
// in every chat completions request — needed for the MCP proxy to expose them to Claude.
if (!agent.tools) {
agent.tools = { profile: "full" };
writeConfig(configPath, config);
}
}

View File

@@ -0,0 +1,20 @@
import { execFileSync } from "node:child_process";
export type AddBaseAgentInput = {
agentId: string;
workspace: string;
bridgeModel: string;
};
/**
* Create the base OpenClaw agent using the `openclaw agents add` CLI.
* This sets up routing and workspace; contractor metadata is written separately.
*/
export function createBaseAgent(input: AddBaseAgentInput): void {
const { agentId, workspace, bridgeModel } = input;
execFileSync(
"openclaw",
["agents", "add", agentId, "--workspace", workspace, "--model", bridgeModel, "--non-interactive"],
{ stdio: "inherit" },
);
}

24
src/types/contractor.ts Normal file
View File

@@ -0,0 +1,24 @@
export type ContractorKind = "claude";
export type ContractorAgentMetadata = {
agentId: string;
contractor: "claude";
bridgeModel: string;
workspace: string;
permissionMode: string;
};
export type PluginConfig = {
bridgePort: number;
bridgeApiKey: string;
permissionMode: string;
};
export function normalizePluginConfig(raw: unknown): PluginConfig {
const cfg = (raw ?? {}) as Partial<PluginConfig>;
return {
bridgePort: cfg.bridgePort ?? 18800,
bridgeApiKey: cfg.bridgeApiKey ?? "contractor-bridge-local",
permissionMode: cfg.permissionMode ?? "bypassPermissions",
};
}

24
src/types/model.ts Normal file
View File

@@ -0,0 +1,24 @@
// Shape of the OpenAI chat completions request body that OpenClaw sends to the bridge sidecar.
// Confirmed via contractor-probe testing: OpenClaw always sends stream:true.
export type OpenAIMessage = {
role: "system" | "user" | "assistant";
content: string | Array<{ type: string; text?: string }>;
};
export type BridgeInboundRequest = {
model: string;
messages: OpenAIMessage[];
stream: boolean;
max_completion_tokens?: number;
tools?: Array<{ type: "function"; function: { name: string; description?: string; parameters?: unknown } }>;
store?: boolean;
};
// Internal payload passed from the HTTP layer to the Claude dispatch layer.
export type ClaudeDispatchPayload = {
agentId: string;
openclawSessionKey: string;
workspace: string;
latestUserMessage: string;
existingClaudeSessionId: string | null;
};

17
src/types/session-map.ts Normal file
View File

@@ -0,0 +1,17 @@
export type SessionMapState = "active" | "closed" | "orphaned";
export type SessionMapEntry = {
openclawSessionKey: string;
agentId: string;
contractor: "claude";
claudeSessionId: string;
workspace: string;
createdAt: string;
lastActivityAt: string;
state: SessionMapState;
};
export type SessionMapFile = {
version: 1;
sessions: SessionMapEntry[];
};

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "scripts"]
}