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:
@@ -372,6 +372,25 @@ export type ContractorBridgeModelRequest = {
|
|||||||
|
|
||||||
Exact fields depend on how OpenClaw provider/model requests are exposed in runtime.
|
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
|
## Suggested implementation order
|
||||||
|
|
||||||
## Milestone 1, project skeleton
|
## Milestone 1, project skeleton
|
||||||
|
|||||||
452
docs/claude/LESSONS_LEARNED.md
Normal file
452
docs/claude/LESSONS_LEARNED.md
Normal 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_end:runId 去重(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 是 string,params 是入参对象
|
||||||
|
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 有延迟(10–30s)。如果在 bot 完全连接前就发送 schedule trigger(`<@bot_id>➡️`),bot 会错过该消息(WS 不推送历史消息)。
|
||||||
|
|
||||||
|
**现象**:发送了 trigger,channel 里能看到消息,但 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 → 进入 tempConnections,assignedIdentifier = "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 agent(model 为 `"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 注册表)。不支持远程工具(运行在其他进程的工具)。完整方案待后续研究。
|
||||||
@@ -2,279 +2,52 @@
|
|||||||
|
|
||||||
## Project Goal
|
## 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
|
## 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:
|
See ARCHITECTURE.md for component design.
|
||||||
|
See IMPLEMENTATION.md for file structure and build order.
|
||||||
- OpenClaw manages agent identity, routing, workspace, and session ownership
|
See BRIDGE_MODEL_FINDINGS.md for probe test results that confirm the design.
|
||||||
- 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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 Scope
|
## Phase 1 Scope
|
||||||
|
|
||||||
### In scope
|
### In scope
|
||||||
|
|
||||||
- Claude contractor agent creation flow
|
- Claude contractor agent provisioning via plugin-owned CLI
|
||||||
- plugin-owned CLI for contractor agent management
|
- `contractor-claude-bridge` model: OpenAI-compatible sidecar that proxies OpenClaw turns into Claude Code sessions
|
||||||
- bridge model placeholder for Claude-backed agents
|
- Session map: persistent mapping of OpenClaw session keys to Claude session UUIDs
|
||||||
- persistent mapping of OpenClaw sessions to Claude sessions
|
- Input filter: strip OpenClaw system prompt, extract latest user message, inject one-time bootstrap
|
||||||
- initial Claude-only runtime design
|
- SSE streaming: bridge sidecar must stream Claude output back as SSE
|
||||||
- planning documents and repository structure
|
- Install script: writes provider config to `openclaw.json`, registers plugin
|
||||||
|
|
||||||
### Out of scope for now
|
### Out of scope for now
|
||||||
|
|
||||||
- Gemini implementation
|
- Gemini implementation
|
||||||
- full OpenClaw skill auto-conversion
|
- Full OpenClaw tool bridging to Claude
|
||||||
- complete tool surface bridging
|
- Advanced recovery and replay semantics
|
||||||
- advanced recovery and replay semantics
|
|
||||||
- UI polish and operational tooling
|
- UI polish and operational tooling
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. What is the best implementation seam for the bridge model inside OpenClaw, provider plugin, tool plugin, or service-backed pseudo model?
|
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?
|
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?
|
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?
|
4. Which minimal OpenClaw tools should be exposed first to Claude? (deferred to later)
|
||||||
5. How should reset/new map to Claude-side session recreation?
|
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
|
1. ~~Verify bridge model registration mechanism~~ Done
|
||||||
2. Define config schema for contractor-backed agents
|
2. ~~Verify Claude session continuation identifier~~ Done
|
||||||
3. Define session-map file schema and lifecycle
|
3. **Build bridge sidecar** with SSE streaming and Claude SDK dispatch
|
||||||
4. Determine how the bridge model id is registered by the plugin
|
4. Implement input filter and session map store
|
||||||
5. Draft implementation skeleton for Claude-only support
|
5. Add bootstrap injection and recovery
|
||||||
|
6. Implement CLI (`openclaw contractor-agents add`)
|
||||||
|
7. Write install script
|
||||||
|
|||||||
28
openclaw.plugin.json
Normal file
28
openclaw.plugin.json
Normal 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
10971
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
189
scripts/install.mjs
Normal 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
49
src/bridge/bootstrap.ts
Normal 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");
|
||||||
|
}
|
||||||
75
src/bridge/input-filter.ts
Normal file
75
src/bridge/input-filter.ts
Normal 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
293
src/bridge/server.ts
Normal 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
217
src/claude/sdk-adapter.ts
Normal 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}` : ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/cli/contractor-agents-add.ts
Normal file
48
src/cli/contractor-agents-add.ts
Normal 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
50
src/cli/register-cli.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/contractor/metadata-resolver.ts
Normal file
33
src/contractor/metadata-resolver.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/contractor/runtime-state.ts
Normal file
15
src/contractor/runtime-state.ts
Normal 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 });
|
||||||
|
}
|
||||||
68
src/contractor/session-map-store.ts
Normal file
68
src/contractor/session-map-store.ts
Normal 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
95
src/index.ts
Normal 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})`);
|
||||||
|
},
|
||||||
|
};
|
||||||
134
src/mcp/openclaw-mcp-server.mjs
Normal file
134
src/mcp/openclaw-mcp-server.mjs
Normal 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);
|
||||||
|
});
|
||||||
49
src/openclaw/agent-config-writer.ts
Normal file
49
src/openclaw/agent-config-writer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/openclaw/agents-add-runner.ts
Normal file
20
src/openclaw/agents-add-runner.ts
Normal 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
24
src/types/contractor.ts
Normal 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
24
src/types/model.ts
Normal 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
17
src/types/session-map.ts
Normal 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
17
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user