Compare commits

...

8 Commits

Author SHA1 Message Date
6ae795eea8 chore: add .gitignore and untrack internal Claude dev notes
- Add .gitignore covering node_modules, .idea, credential files
- Untrack docs/claude/LESSONS_LEARNED.md and OPENCLAW_PLUGIN_DEV.md
  (internal dev notes, not part of the plugin repo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:28:56 +01:00
07a0f06e2e refactor: restructure to plugin/ + services/ layout and add per-turn bootstrap injection
- Migrate src/ → plugin/ (plugin/core/, plugin/web/, plugin/commands/)
  and src/mcp/ → services/ per OpenClaw plugin dev spec
- Add Gemini CLI backend (plugin/core/gemini/sdk-adapter.ts) with GEMINI.md
  system-prompt injection
- Inject bootstrap as stateless system prompt on every turn instead of
  first turn only: Claude via --system-prompt, Gemini via workspace/GEMINI.md;
  eliminates isFirstTurn branch, keeps skills in sync with OpenClaw snapshots
- Fix session-map-store defensive parsing (sessions ?? []) to handle bare {}
  reset files without crashing on .find()
- Add docs/TEST_FLOW.md with E2E test scenarios and expected outcomes
- Add docs/claude/BRIDGE_MODEL_FINDINGS.md with contractor-probe results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:21:32 +01:00
eee62efbf1 feat: add openclaw origin-name alias to MCP tool descriptions
Claude Code sees tools as mcp__openclaw__<name> but skills/instructions
reference the original OpenClaw name (e.g. "memory_search"). Each tool
description now includes a disambiguation line:

  [openclaw tool: <name>] If any skill or instruction refers to "<name>",
  this is the tool to call.

This ensures Claude can correctly map skill references to the right tool
even when the MCP prefix makes the name appear different.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:25:33 +01:00
7a779c8560 feat: relay MCP tool calls to OpenClaw plugin registry instead of reimplementing
When Claude Code calls an MCP tool via /mcp/execute, the bridge now:
1. Looks up the tool in OpenClaw's global plugin registry (getGlobalPluginRegistry)
2. Calls the tool's factory with proper context (config from globalThis, canonical
   session key "agent:<agentId>:direct:bridge" for memory agentId resolution)
3. Executes the real tool implementation and returns the AgentToolResult text

This replaces the manual memory_search/memory_get implementations in memory-handlers.ts
with a generic relay that works for any registered OpenClaw tool. The agentId is now
propagated from dispatchToClaude through the MCP server env (AGENT_ID) to /mcp/execute.

The OpenClaw config is stored in globalThis._contractorOpenClawConfig during plugin
registration (index.ts api.config) since getRuntimeConfigSnapshot() uses module-level
state not shared across bundle boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:09:02 +01:00
f9d43477bf feat: implement memory_search/memory_get MCP tools and fix resume MCP disconnect
Key fixes:
- Pass --mcp-config on every turn (not just first): MCP server exits with each
  claude -p process, so --resume also needs a fresh MCP server
- Pass openclawTools on every turn for the same reason
- Add WORKSPACE env var to MCP server so execute requests include workspace path
- Implement memory_search (keyword search over workspace/memory/*.md + MEMORY.md)
- Implement memory_get (line-range read from workspace memory files)
- Both are workspace-aware, resolved per-request via request body workspace field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 15:55:44 +01:00
1d9b765a6d docs: document Claude Code identity/SOUL.md conflict analysis (14.9)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 15:46:51 +01:00
fb1ec1b5c4 feat: inject SOUL.md/IDENTITY.md persona and detect workspace context files
- input-filter: scan workspace for SOUL.md, IDENTITY.md, MEMORY.md etc. on each turn
- bootstrap: when these files exist, instruct Claude to Read them at session start
- This gives the contractor agent its OpenClaw persona (SOUL.md embody, IDENTITY.md fill)
- Memory note added to bootstrap when workspace has memory files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 15:41:25 +01:00
76a7931f97 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>
2026-04-11 13:05:03 +01:00
33 changed files with 13862 additions and 256 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Internal Claude dev notes — not for repo
docs/claude/LESSONS_LEARNED.md
docs/claude/OPENCLAW_PLUGIN_DEV.md
# Credentials and tokens
CLAUDE_CONTRACTOR_TEST_TOKEN
# IDE
.idea/

236
docs/TEST_FLOW.md Normal file
View File

@@ -0,0 +1,236 @@
# ContractorAgent E2E Test Flow
End-to-end test scenarios for the contractor-agent plugin. Each scenario describes
the precondition, the action, and the expected observable outcome.
---
## Setup
### ST-1 — Clean install
**Precondition**: Plugin not installed; test agent workspaces do not exist.
**Steps**:
1. `node scripts/install.mjs --uninstall` (idempotent if already gone)
2. Remove test agents from `openclaw.json` if present
3. `node scripts/install.mjs --install`
4. `openclaw gateway restart`
**Expected**:
- `GET /health``{"ok":true,"service":"contractor-bridge"}`
- `GET /v1/models` → list includes `contractor-claude-bridge` and `contractor-gemini-bridge`
- OpenClaw logs: `[contractor-agent] plugin registered (bridge port: 18800)`
---
### ST-2 — Provision Claude contractor agent
**Command**: `openclaw contractor-agents add --agent-id claude-e2e --workspace /tmp/claude-e2e-workspace --contractor claude`
**Expected**:
- `openclaw.json` agents list contains `claude-e2e` with model `contractor-agent/contractor-claude-bridge`
- `/tmp/claude-e2e-workspace/.openclaw/contractor-agent/session-map.json` exists (empty sessions)
- Workspace files created by `openclaw agents add` are present (SOUL.md, IDENTITY.md, etc.)
---
### ST-3 — Provision Gemini contractor agent
**Command**: `openclaw contractor-agents add --agent-id gemini-e2e --workspace /tmp/gemini-e2e-workspace --contractor gemini`
**Expected**: same as ST-2 but model is `contractor-agent/contractor-gemini-bridge`.
---
## Core Bridge Behaviour
### CB-1 — First turn bootstraps persona (Claude)
**Precondition**: `claude-e2e` has no active session (session-map empty).
**Request**: POST `/v1/chat/completions`, model `contractor-agent/contractor-claude-bridge`,
system message contains `Runtime: agent=claude-e2e | repo=/tmp/claude-e2e-workspace`,
user message: `"Introduce yourself briefly."`
**Expected**:
- Response streams SSE chunks, terminates with `[DONE]`
- Response reflects the persona from `SOUL.md`/`IDENTITY.md` — agent uses the name
and tone defined in the workspace files (no generic "I'm Claude …" dry response)
- `session-map.json` now contains one entry with `contractor=claude`, `state=active`,
and a non-empty `claudeSessionId`
**Mechanism**: bootstrap is injected only on the first turn; it embeds the content of
SOUL.md, IDENTITY.md, USER.md, MEMORY.md inline so the agent adopts the persona
immediately without needing to read files itself.
---
### CB-2 — Session resume retains context (Claude)
**Precondition**: CB-1 has run; session-map holds an active session.
**Request**: same headers, user message: `"What did I ask you in my first message?"`
**Expected**:
- Agent recalls the previous question without re-reading files
- `session-map.json` `lastActivityAt` updated; `claudeSessionId` unchanged
**Mechanism**: bridge detects existing active session, passes `--resume <sessionId>` to
Claude Code; bootstrap is NOT re-injected.
---
### CB-3 — MCP tool relay — contractor_echo (Claude)
**Precondition**: active session from CB-1; request includes `contractor_echo` tool definition.
**Request**: user message: `"Use the contractor_echo tool to echo: hello"`
**Expected**:
- Agent calls the `mcp__openclaw__contractor_echo` tool via the MCP proxy
- Bridge relays the call to `POST /mcp/execute` → OpenClaw plugin registry
- Response confirms the echo with a timestamp: `"Echo confirmed: hello at …"`
**Mechanism**: bridge writes an MCP config file pointing to
`services/openclaw-mcp-server.mjs` before each `claude` invocation; the MCP server
forwards tool calls to the bridge `/mcp/execute` endpoint which resolves them through
the OpenClaw global plugin registry.
---
### CB-4 — First turn bootstraps persona (Gemini)
Same as CB-1 but model `contractor-agent/contractor-gemini-bridge`, agent `gemini-e2e`.
**Expected**: persona from `SOUL.md`/`IDENTITY.md` is reflected; session entry has
`contractor=gemini`.
**Mechanism**: bootstrap is identical; Gemini CLI receives the full prompt via `-p`.
MCP config is written to `workspace/.gemini/settings.json` (Gemini's project settings
path) instead of an `--mcp-config` flag.
---
### CB-5 — Session resume (Gemini)
Same as CB-2 but for Gemini.
**Expected**: agent recalls prior context via `--resume <UUID>`.
---
### CB-6 — MCP tool relay (Gemini)
Same as CB-3 but for Gemini. Gemini sees the tool as `mcp_openclaw_contractor_echo`
(single-underscore FQN; server alias is `openclaw` with no underscores).
**Expected**: echo confirmed in response.
---
## Skill Invocation
### SK-1 — Agent reads and executes a skill script (Claude)
**Precondition**: fresh session (session-map cleared); skill
`contractor-test-skill` is installed in `~/.openclaw/skills/contractor-test-skill/`.
System message includes:
```xml
<available_skills>
<skill>
<name>contractor-test-skill</name>
<description>Test skill for verifying that a contractor agent can discover and invoke a workspace script…</description>
<location>/home/hzhang/.openclaw/skills/contractor-test-skill</location>
</skill>
</available_skills>
```
**Request**: user message: `"Run the contractor test skill and show me the output."`
**Expected**:
- Agent reads `SKILL.md` at the given `<location>`
- Expands `{baseDir}``/home/hzhang/.openclaw/skills/contractor-test-skill`
- Executes `scripts/test.sh` via Bash
- Response contains:
```
=== contractor-test-skill: PASSED ===
Timestamp: <ISO-8601>
```
**Why first turn matters**: the bootstrap embeds the `<available_skills>` block in the
system prompt sent to Claude on the first turn. Claude retains this in session memory
for subsequent turns. If the session is already active when the skill-bearing request
arrives, Claude won't know about the skill.
---
### SK-2 — Agent reads and executes a skill script (Gemini)
Same as SK-1 but for Gemini. Gemini reads SKILL.md and executes the script.
**Expected**: same `PASSED` output block.
---
## Skills Injection Timing
### Background — How OpenClaw injects skills
OpenClaw rebuilds the system prompt (including `<available_skills>`) on **every turn**
and sends it to the model. The skill list comes from a **snapshot** cached in the
session entry, refreshed under the following conditions:
| Trigger | Refresh? |
|---------|----------|
| First turn in a new session | ✅ Always |
| Skills directory file changed (file watcher detects version bump) | ✅ Yes |
| `openclaw gateway restart` (session entry survives) | ❌ No — old snapshot reused |
| `openclaw gateway restart` + session reset / new session | ✅ Yes — first-turn logic runs |
| Skill filter changes | ✅ Yes |
### Implication for the contractor bridge
The bridge receives the skills block on **every** incoming turn but currently only uses
it in the **first-turn bootstrap**. Subsequent turns carry updated skills if OpenClaw
refreshed the snapshot, but the bridge does not re-inject them into the running
Claude/Gemini session.
**Consequence**: if skills are added or removed while a contractor session is active,
the agent won't see the change until the session is reset (session-map cleared) and a
new bootstrap is sent.
This is intentional for v1: contractor sessions are meant to be long-lived, and skill
changes mid-session are uncommon. If needed, explicitly clearing the session map forces
a new bootstrap on the next turn.
---
## Error Cases
### ER-1 — No active session, no workspace in system message
**Request**: system message has no `Runtime:` line.
**Expected**: bridge logs a warning, falls back to `/tmp` workspace, session key is empty,
response may succeed but session is not persisted.
---
### ER-2 — Gemini CLI not installed
**Request**: model `contractor-agent/contractor-gemini-bridge`.
**Expected**: `dispatchToGemini` spawn fails, bridge streams
`[contractor-bridge dispatch failed: …]` error chunk, then `[DONE]`.
Session is not persisted; if a prior session existed, it is marked `orphaned`.
---
### ER-3 — MCP tool not registered in plugin registry
**Request**: tool `unknown_tool` called via `/mcp/execute`.
**Expected**: `POST /mcp/execute` returns `{"error":"Tool 'unknown_tool' not registered…"}` (200).
The agent receives the error text as the tool result and surfaces it in its reply.

View File

@@ -0,0 +1,258 @@
# Bridge Model Probe Findings
## Purpose
Document actual test results from running the `contractor-probe` test plugin against a live
OpenClaw gateway. Resolves the two critical unknowns identified in earlier feasibility review.
Test setup: installed `contractor-probe` plugin that exposes an OpenAI-compatible HTTP server
on port 8799 and logs every raw request body to `/tmp/contractor-probe-requests.jsonl`.
Created a `probe-test` agent with `model: contractor-probe/contractor-probe-bridge`.
Sent two consecutive messages via `openclaw agent --channel qa-channel`.
---
## Finding 1 — Custom Model Registration Mechanism
**There is no plugin SDK `registerModelProvider` call.**
The actual mechanism used by dirigent (and confirmed working for contractor-probe) is:
### Step 1 — Add provider to `openclaw.json`
Under `models.providers`, add an entry pointing to a local OpenAI-compatible HTTP server:
```json
"contractor-probe": {
"baseUrl": "http://127.0.0.1:8799/v1",
"apiKey": "probe-local",
"api": "openai-completions",
"models": [{
"id": "contractor-probe-bridge",
"name": "Contractor Probe Bridge (test)",
"reasoning": false,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 200000,
"maxTokens": 4096
}]
}
```
### Step 2 — Plugin starts a sidecar HTTP server
The plugin's `register()` function starts a Node.js HTTP server on `gateway_start` (protected
by a `globalThis` flag to prevent double-start on hot-reload). The server implements:
- `GET /v1/models` — model list
- `POST /v1/chat/completions` — model inference (must support streaming)
- `POST /v1/responses` — responses API variant (optional)
### Step 3 — Agent uses `provider/model` as primary model
```json
{
"id": "my-agent",
"model": { "primary": "contractor-probe/contractor-probe-bridge" }
}
```
### What this means for ContractorAgent
The `contractor-claude-bridge` model should be registered the same way:
1. Install script writes a provider entry to `openclaw.json` pointing to the bridge sidecar port
2. Plugin starts the bridge sidecar on `gateway_start`
3. `openclaw contractor-agents add` sets the agent's primary model to `contractor-claude-bridge`
No plugin SDK model registration API exists or is needed.
---
## Finding 2 — Exact Payload Sent to Custom Model
OpenClaw sends a standard OpenAI Chat Completions request to the sidecar on every turn.
### Endpoint and transport
```
POST /v1/chat/completions
Content-Type: application/json
stream: true ← streaming is always requested; sidecar MUST emit SSE
```
### Message array structure
**Turn 1 (2 messages):**
| index | role | content |
|-------|------|---------|
| 0 | `system` | Full OpenClaw agent context (~28,000 chars) — rebuilt every turn |
| 1 | `user` | `[Sat 2026-04-11 08:32 GMT+1] hello from probe test` |
**Turn 2 (3 messages):**
| index | role | content |
|-------|------|---------|
| 0 | `system` | Same full context (~28,000 chars) |
| 1 | `user` | `[Sat 2026-04-11 08:32 GMT+1] hello from probe test` |
| 2 | `user` | `[Sat 2026-04-11 08:34 GMT+1] and this is the second message` |
Note: the probe sidecar did not emit proper SSE. As a result, turn 2 shows no assistant message
between the two user messages. Once the bridge sidecar returns well-formed SSE, OpenClaw should
include assistant turns in history. Needs follow-up verification with streaming.
### System prompt contents
The system prompt is assembled by OpenClaw from:
- OpenClaw base instructions (tool call style, scheduling rules, ACP guidance)
- Workspace context files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, BOOTSTRAP.md)
- Skill definitions
- Tool guidance text
In the test, total system prompt was 28,942 chars. This is rebuilt from scratch on every turn.
### User message format
```
[Day YYYY-MM-DD HH:MM TZ] <message text>
```
Example: `[Sat 2026-04-11 08:32 GMT+1] hello from probe test`
### Tool definitions
Full OpenAI function definitions are sent on every request as `tools: [...]`. In the test run,
37 tools were included (read, edit, exec, cron, message, sessions_spawn, dirigent tools, etc.).
### Other request fields
| field | observed value | notes |
|-------|---------------|-------|
| `model` | `contractor-probe-bridge` | model id as configured |
| `stream` | `true` | always; bridge must stream |
| `store` | `false` | |
| `max_completion_tokens` | `4096` | from provider model config |
### What this means for ContractorAgent
**The input filter is critical.** On every turn, OpenClaw sends:
- A large system prompt (28K+ chars) that repeats unchanged
- The full accumulated user message history
- No (or incomplete) assistant message history
The bridge model must NOT forward this verbatim to Claude Code. Instead:
1. Extract only the latest user message from the messages array (last `user` role entry)
2. Strip the OpenClaw system prompt entirely — Claude Code maintains its own live context
3. On first turn: inject a one-time bootstrap block telling Claude it is operating as an
OpenClaw contractor agent, with workspace path and session key
4. On subsequent turns: forward only the latest user message text
This keeps Claude as the owner of its own conversational context and avoids dual-context drift.
**The bridge sidecar must support SSE streaming.** OpenClaw always sets `stream: true`. A
non-streaming response causes assistant turn data to be dropped from OpenClaw's session history.
---
## Finding 3 — Claude Code Session Continuation Identifier
From Claude Code documentation research:
### Session ID format
UUIDs assigned at session creation. Example: `bc1a7617-0651-443d-a8f1-efeb2957b8c2`
### Session storage
```
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
```
`<encoded-cwd>` is the absolute working directory path with every non-alphanumeric character
replaced by `-`:
- `/home/user/project``-home-user-project`
### CLI resumption
```bash
# Non-interactive mode with session resume
claude -p --resume <session-uuid> "next user message"
```
`-p` is print/non-interactive mode. The session UUID is passed as the resume argument.
The output includes the session ID so the caller can capture it for next turn.
### SDK resumption (TypeScript)
```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "next user message",
options: {
resume: sessionId, // UUID from previous turn
allowedTools: ["Read", "Edit", "Glob"],
}
})) {
if (message.type === "result") {
const nextSessionId = message.session_id; // capture for next turn
}
}
```
### Session enumeration
```typescript
import { listSessions, getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
const sessions = await listSessions(); // keyed by session UUID
const info = await getSessionInfo(sessionId);
```
### What this means for ContractorAgent
The `SessionMapEntry.claudeSessionId` field should store the UUID returned by `message.session_id`
after each Claude turn. On the next turn, pass it as `options.resume`.
The session file path can be reconstructed from the session ID and workspace path if needed for
recovery or inspection, but direct SDK resumption is the primary path.
---
## Finding 4 — Sidecar Port Conflict and Globalthis Guard
During testing, the probe sidecar failed to start with `EADDRINUSE` when `openclaw agent --local`
was used alongside a running gateway, because both tried to spawn the server process.
This is exactly the hot-reload / double-start problem documented in LESSONS_LEARNED items 1, 3,
and 7. The fix for the bridge sidecar:
1. Check a lock file (e.g. `/tmp/contractor-bridge-sidecar.lock`) before starting
2. If the lock file exists and the PID is alive, skip start
3. Protect the `startSidecar` call with a `globalThis` flag
4. Clean up the lock file on `gateway_stop`
---
## Open Questions Resolved
| Question | Status | Answer |
|----------|--------|--------|
| Custom model registration API? | ✅ Resolved | `openclaw.json` provider config + OpenAI-compatible sidecar |
| Claude session continuation identifier? | ✅ Resolved | UUID via `message.session_id`, resume via `options.resume` |
| Does OpenClaw include assistant history? | ⚠️ Partial | Appears yes when sidecar streams correctly; needs retest with SSE |
| Streaming required? | ✅ Resolved | Yes, `stream: true` always sent; non-streaming drops assistant history |
---
## Immediate Next Steps (Updated)
1. **Add SSE streaming to probe sidecar** — retest to confirm assistant messages appear in turn 3
2. **Build the real bridge sidecar** — implement SSE passthrough from Claude SDK output
3. **Implement input filter** — extract latest user message, strip system prompt
4. **Implement session map store** — persist UUID → OpenClaw session key mapping
5. **Implement bootstrap injection** — first-turn only; include workspace path and session key
6. **Add lock file to sidecar** — prevent double-start (LESSONS_LEARNED lesson 7)

View File

@@ -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

View File

@@ -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

10971
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "contractor-agent",
"version": "0.1.0",
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
"type": "module",
"main": "plugin/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": "*"
}
}

View File

@@ -0,0 +1,53 @@
import fs from "node:fs";
import { createBaseAgent } from "../core/openclaw/agents-add-runner.js";
import { markAgentAsClaudeContractor, markAgentAsGeminiContractor } from "../core/openclaw/agent-config-writer.js";
import { ensureContractorStateDir } from "../core/contractor/runtime-state.js";
import { initEmptySessionMap } from "../core/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" && contractor !== "gemini") {
throw new Error(`--contractor ${contractor}: must be 'claude' or 'gemini'`);
}
const bridgeModel =
contractor === "gemini"
? "contractor-agent/contractor-gemini-bridge"
: "contractor-agent/contractor-claude-bridge";
// 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 });
console.log(`[contractor-agent] Writing contractor metadata`);
if (contractor === "gemini") {
markAgentAsGeminiContractor(agentId, workspace);
} else {
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: ${bridgeModel}`);
console.log(` State dir: ${workspace}/.openclaw/contractor-agent/`);
}

View File

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

View File

@@ -0,0 +1,235 @@
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;
/** System prompt passed via --system-prompt on every invocation (stateless, not stored in session) */
systemPrompt?: string;
workspace: string;
agentId?: 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.
// Installed layout: plugin root / core / claude / sdk-adapter.ts
// plugin root / services / openclaw-mcp-server.mjs
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../../services/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,
workspace: string,
agentId: 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,
WORKSPACE: workspace,
AGENT_ID: agentId,
},
},
},
};
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,
systemPrompt,
workspace,
agentId = "",
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",
];
// --system-prompt is stateless (not persisted in session file) and fully
// replaces any prior system prompt on each invocation, including resumes.
// We pass it every turn so skills/persona stay current.
if (systemPrompt) {
args.push("--system-prompt", systemPrompt);
}
if (resumeSessionId) {
args.push("--resume", resumeSessionId);
}
// Set up MCP proxy every turn — the MCP server process exits with each `claude -p`
// invocation, so --resume sessions also need --mcp-config to restart it.
// Put --mcp-config after the prompt so its <configs...> variadic doesn't consume the prompt.
let mcpConfigPath: string | null = null;
if (openclawTools?.length) {
mcpConfigPath = setupMcpConfig(openclawTools, bridgePort, bridgeApiKey, workspace, agentId);
if (mcpConfigPath) {
args.push("--mcp-config", mcpConfigPath);
}
}
const child = spawn("claude", args, {
cwd: workspace,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
const stderrLines: string[] = [];
child.stderr?.on("data", (chunk: Buffer) => {
stderrLines.push(chunk.toString("utf8").trim());
});
const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
let capturedSessionId = "";
const events: ClaudeMessage[] = [];
let done = false;
let resolveNext: (() => void) | null = null;
rl.on("line", (line: string) => {
if (!line.trim()) return;
let event: Record<string, unknown>;
try {
event = JSON.parse(line);
} catch {
return;
}
const type = event.type as string;
if (type === "assistant") {
const msg = event.message as { content?: Array<{ type: string; text?: string }> };
for (const block of msg?.content ?? []) {
if (block.type === "text" && block.text) {
events.push({ type: "text", text: block.text });
}
}
}
if (type === "result") {
const sessionId = (event.session_id as string) ?? "";
if (sessionId) capturedSessionId = sessionId;
}
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r();
}
});
rl.on("close", () => {
done = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r();
}
});
while (true) {
if (events.length > 0) {
yield events.shift()!;
continue;
}
if (done) break;
await new Promise<void>((resolve) => {
resolveNext = resolve;
});
}
while (events.length > 0) {
yield events.shift()!;
}
await new Promise<void>((resolve) => {
child.on("close", resolve);
if (child.exitCode !== null) resolve();
});
// Clean up temp files
if (mcpConfigPath) {
try { fs.unlinkSync(mcpConfigPath); } catch { /* ignore */ }
// tool defs file path is embedded in the config — leave it for now
}
if (capturedSessionId) {
yield { type: "done", sessionId: capturedSessionId };
} else {
const stderrSummary = stderrLines.join(" ").slice(0, 200);
yield {
type: "error",
message: `claude did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`,
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
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 {
const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<SessionMapFile>;
return { version: parsed.version ?? 1, sessions: parsed.sessions ?? [] };
} 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: [] });
}
}

View File

@@ -0,0 +1,243 @@
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";
import type { ClaudeMessage, OpenAITool } from "../claude/sdk-adapter.js";
// Resolve the MCP server script path relative to this file.
// Installed layout: plugin root / core / gemini / sdk-adapter.ts
// plugin root / services / openclaw-mcp-server.mjs
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../../services/openclaw-mcp-server.mjs");
export type GeminiDispatchOptions = {
prompt: string;
/**
* System-level instructions written to workspace/GEMINI.md before each invocation.
* Gemini CLI reads GEMINI.md from cwd on every turn (including resumes) — it is NOT
* stored in the session file — so updating it takes effect immediately.
*/
systemPrompt?: string;
workspace: string;
agentId?: string;
resumeSessionId?: string;
/** Gemini model override (e.g. "gemini-2.5-flash"). Defaults to gemini-cli's configured default. */
model?: string;
/** OpenClaw tool definitions to expose via MCP */
openclawTools?: OpenAITool[];
bridgePort?: number;
bridgeApiKey?: string;
};
/**
* Write the OpenClaw MCP server config to the workspace's .gemini/settings.json.
*
* Unlike Claude's --mcp-config flag, Gemini CLI reads MCP configuration from the
* project settings file (.gemini/settings.json in the cwd). We write it before
* each invocation to keep the tool list in sync with the current turn's tools.
*
* The file is merged with any existing settings to preserve user/project config.
*/
function setupGeminiMcpSettings(
tools: OpenAITool[],
bridgePort: number,
bridgeApiKey: string,
workspace: string,
agentId: string,
): void {
const geminiDir = path.join(workspace, ".gemini");
const settingsPath = path.join(geminiDir, "settings.json");
fs.mkdirSync(geminiDir, { recursive: true });
// Preserve existing non-MCP settings
let existing: Record<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
existing = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
} catch {
// corrupt settings — start fresh
}
}
if (!tools.length || !fs.existsSync(MCP_SERVER_SCRIPT)) {
delete existing.mcpServers;
} else {
const tmpDir = os.tmpdir();
const sessionId = `oc-${Date.now()}`;
const toolDefsPath = path.join(tmpDir, `${sessionId}-tools.json`);
fs.writeFileSync(toolDefsPath, JSON.stringify(tools, null, 2), "utf8");
// Gemini MCP server config — server alias must not contain underscores
// (Gemini uses underscores as FQN separator: mcp_<alias>_<toolname>).
// We use "openclaw" (no underscores) to keep names clean.
existing.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,
WORKSPACE: workspace,
AGENT_ID: agentId,
},
trust: true, // auto-approve MCP tool calls (equivalent to Claude's bypassPermissions)
},
};
}
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8");
}
/**
* Dispatch a turn to Gemini CLI using `gemini -p --output-format stream-json`.
* Returns an async iterable of ClaudeMessage events (same interface as Claude adapter).
*
* Stream-json event types from Gemini CLI:
* init → { session_id, model }
* message → { role: "assistant", content: string, delta: true } (streaming text)
* tool_use → informational (Gemini handles internally)
* tool_result → informational (Gemini handles internally)
* result → { status, stats }
*/
export async function* dispatchToGemini(
opts: GeminiDispatchOptions,
): AsyncIterable<ClaudeMessage> {
const {
prompt,
systemPrompt,
workspace,
agentId = "",
resumeSessionId,
model,
openclawTools,
bridgePort = 18800,
bridgeApiKey = "",
} = opts;
// Write system-level instructions to workspace/GEMINI.md every turn.
// Gemini CLI reads GEMINI.md from cwd on every invocation (including resumes)
// and does NOT store it in the session file, so this acts as a stateless system prompt.
if (systemPrompt) {
fs.writeFileSync(path.join(workspace, "GEMINI.md"), systemPrompt, "utf8");
}
// Write MCP config to workspace .gemini/settings.json every turn.
// Gemini CLI restarts the MCP server process each invocation (like Claude),
// so we must re-inject the config on every turn including resumes.
if (openclawTools?.length) {
setupGeminiMcpSettings(openclawTools, bridgePort, bridgeApiKey, workspace, agentId);
}
// NOTE: prompt goes right after -p before other flags to avoid ambiguity.
const args: string[] = [
"-p",
prompt,
"--output-format", "stream-json",
"--approval-mode", "yolo",
];
if (model) {
args.push("-m", model);
}
if (resumeSessionId) {
args.push("--resume", resumeSessionId);
}
const child = spawn("gemini", 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; // ignore non-JSON lines (e.g. "YOLO mode is enabled." warning)
}
const type = event.type as string;
// init event carries the session_id for future --resume
if (type === "init") {
const sid = event.session_id as string;
if (sid) capturedSessionId = sid;
}
// Streaming assistant text: message events with delta:true
if (type === "message" && event.role === "assistant" && event.delta) {
const content = event.content as string;
if (content) {
events.push({ type: "text", text: content });
}
}
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();
});
if (capturedSessionId) {
yield { type: "done", sessionId: capturedSessionId };
} else {
const stderrSummary = stderrLines
.join(" ")
.replace(/YOLO mode is enabled\./g, "")
.trim()
.slice(0, 200);
yield {
type: "error",
message: `gemini did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`,
};
}
}

View File

@@ -0,0 +1,58 @@
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 CLAUDE_CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
const GEMINI_CONTRACTOR_MODEL = "contractor-agent/contractor-gemini-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.
*/
function markContractorAgent(agentId: string, expectedModel: string, configPath: string, config: OpenClawConfig): void {
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 !== expectedModel) {
throw new Error(
`agent ${agentId} model is "${String(agent.model)}", expected "${expectedModel}"`,
);
}
// Ensure tools are enabled so OpenClaw includes tool definitions in every
// chat completions request — needed for the MCP proxy to expose them to Claude/Gemini.
if (!agent.tools) {
agent.tools = { profile: "full" };
writeConfig(configPath, config);
}
}
export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void {
const { config, configPath } = readConfig();
markContractorAgent(agentId, CLAUDE_CONTRACTOR_MODEL, configPath, config);
}
export function markAgentAsGeminiContractor(agentId: string, _workspace: string): void {
const { config, configPath } = readConfig();
markContractorAgent(agentId, GEMINI_CONTRACTOR_MODEL, configPath, config);
}

View File

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

View File

@@ -0,0 +1,24 @@
export type ContractorKind = "claude" | "gemini";
export type ContractorAgentMetadata = {
agentId: string;
contractor: ContractorKind;
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",
};
}

View File

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

View File

@@ -0,0 +1,18 @@
export type SessionMapState = "active" | "closed" | "orphaned";
export type SessionMapEntry = {
openclawSessionKey: string;
agentId: string;
contractor: "claude" | "gemini";
/** Opaque session ID used to resume the underlying CLI session (--resume). */
claudeSessionId: string;
workspace: string;
createdAt: string;
lastActivityAt: string;
state: SessionMapState;
};
export type SessionMapFile = {
version: 1;
sessions: SessionMapEntry[];
};

101
plugin/index.ts Normal file
View File

@@ -0,0 +1,101 @@
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 "./core/types/contractor.js";
import { resolveContractorAgentMetadata } from "./core/contractor/metadata-resolver.js";
import { createBridgeServer } from "./web/server.js";
import { registerCli } from "./commands/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";
/** Key for the live OpenClaw config accessor (getter fn) shared via globalThis. */
const OPENCLAW_CONFIG_KEY = "_contractorOpenClawConfig";
// ── 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)
// Always update the config accessor so hot-reloads get fresh config.
// server.ts reads this via globalThis to build tool execution context.
_G[OPENCLAW_CONFIG_KEY] = api.config;
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;
// Only bind if port is not already in use (avoids EADDRINUSE in CLI mode)
const portFree = await isPortFree(config.bridgePort);
if (!portFree) {
api.logger.info(
`[contractor-agent] bridge already running on port ${config.bridgePort}, skipping bind`,
);
return;
}
const server = createBridgeServer({
port: config.bridgePort,
apiKey: config.bridgeApiKey,
permissionMode: config.permissionMode,
resolveAgent,
logger: api.logger,
});
_G[SERVER_KEY] = server;
api.on("gateway_stop", () => {
const s = _G[SERVER_KEY] as http.Server | undefined;
if (s) s.close();
api.logger.info("[contractor-agent] bridge server stopped");
});
}
// ── CLI ───────────────────────────────────────────────────────────────────
registerCli(api);
api.logger.info(`[contractor-agent] plugin registered (bridge port: ${config.bridgePort})`);
},
};

View File

@@ -0,0 +1,28 @@
{
"id": "contractor-agent",
"name": "Contractor Agent",
"version": "0.1.0",
"description": "Turns Claude Code into an OpenClaw-managed contractor agent",
"main": "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"
}
}
}
}

14
plugin/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "contractor-agent",
"version": "0.1.0",
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
"type": "module",
"main": "index.ts",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.101"
},
"devDependencies": {
"typescript": "^5.0.0",
"openclaw": "*"
}
}

117
plugin/web/bootstrap.ts Normal file
View File

@@ -0,0 +1,117 @@
import fs from "node:fs";
import path from "node:path";
export type BootstrapInput = {
agentId: string;
openclawSessionKey: string;
workspace: string;
/** Skills XML block extracted from the OpenClaw system prompt, if any */
skillsBlock?: string;
/** Subset of OpenClaw context files present in the workspace (for persona/identity) */
workspaceContextFiles?: string[];
};
/** Read a workspace file. Returns the content, or null if missing/unreadable. */
function readWorkspaceFile(workspace: string, filename: string): string | null {
try {
const content = fs.readFileSync(path.join(workspace, filename), "utf8").trim();
return content || null;
} catch {
return null;
}
}
/**
* Build the one-time bootstrap message injected at the start of a new session.
* Workspace context files (SOUL.md, IDENTITY.md, MEMORY.md, USER.md) are read
* here and embedded inline — the agent must not need to fetch them separately.
* Must NOT be re-injected on every turn.
*/
export function buildBootstrap(input: BootstrapInput): string {
const contextFiles = input.workspaceContextFiles ?? [];
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.`,
];
// ── Persona (SOUL.md) ─────────────────────────────────────────────────────
// Injected directly so the agent embodies the persona immediately without
// needing to read files first.
if (contextFiles.includes("SOUL.md")) {
const soul = readWorkspaceFile(input.workspace, "SOUL.md");
if (soul) {
lines.push(``, `## Soul`, soul);
}
}
// ── Identity (IDENTITY.md) ────────────────────────────────────────────────
if (contextFiles.includes("IDENTITY.md")) {
const identity = readWorkspaceFile(input.workspace, "IDENTITY.md");
if (identity) {
lines.push(``, `## Identity`, identity);
// If the file still looks like the default template, encourage the agent
// to fill it in.
if (identity.includes("Fill this in during your first conversation")) {
lines.push(
``,
`_IDENTITY.md is still a template. Pick a name, creature type, and vibe for yourself`,
`and update the file at ${input.workspace}/IDENTITY.md._`,
);
}
}
}
// ── User profile (USER.md) ────────────────────────────────────────────────
if (contextFiles.includes("USER.md")) {
const user = readWorkspaceFile(input.workspace, "USER.md");
if (user) {
lines.push(``, `## User Profile`, user);
}
}
// ── Memory index (MEMORY.md) ──────────────────────────────────────────────
if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) {
const memoryIndex = readWorkspaceFile(input.workspace, "MEMORY.md");
lines.push(``, `## Memory`);
if (memoryIndex) {
lines.push(memoryIndex);
}
lines.push(
``,
`Memory files live in ${input.workspace}/memory/. Use the Read tool to fetch individual`,
`memories and write new ones to ${input.workspace}/memory/<topic>.md.`,
);
}
// ── Skills ────────────────────────────────────────────────────────────────
if (input.skillsBlock) {
lines.push(
``,
`## Skills`,
`The following skills are available. When a task matches a skill's description:`,
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
``,
input.skillsBlock,
);
}
return lines.join("\n");
}

View File

@@ -0,0 +1,95 @@
import fs from "node:fs";
import path from "node:path";
import type { BridgeInboundRequest, OpenAIMessage } from "../core/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;
/** OpenClaw context files present in the workspace (SOUL.md, IDENTITY.md, etc.) */
workspaceContextFiles: 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: "", workspaceContextFiles: [] };
const text = messageText(systemMsg);
// Match "Runtime: agent=<id> | ... | repo=<path> | ..."
const runtimeMatch = text.match(/Runtime:\s*([^\n]+)/);
if (!runtimeMatch) return { agentId: "", workspace: "", skillsBlock: "", workspaceContextFiles: [] };
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}/`)
: "";
// Detect which OpenClaw context files are present in the workspace.
// These tell us what persona/memory files to surface to Claude.
const workspace = repoMatch?.[1] ?? "";
const CONTEXT_FILES = ["SOUL.md", "IDENTITY.md", "MEMORY.md", "AGENTS.md", "USER.md"];
const workspaceContextFiles: string[] = [];
if (workspace) {
for (const f of CONTEXT_FILES) {
if (fs.existsSync(path.join(workspace, f))) workspaceContextFiles.push(f);
}
// Also check for memory/ directory
if (fs.existsSync(path.join(workspace, "memory"))) {
workspaceContextFiles.push("memory/");
}
}
return {
agentId: agentMatch?.[1] ?? "",
workspace,
skillsBlock,
workspaceContextFiles,
};
}

View File

@@ -0,0 +1,162 @@
import fs from "node:fs";
import path from "node:path";
/**
* Implementations of OpenClaw memory tools for the contractor bridge.
*
* OpenClaw's real memory_search uses vector/semantic search; we approximate
* with keyword matching across workspace memory files. memory_get is a
* line-range file read and is implemented exactly.
*
* These handlers are registered in globalThis._contractorToolHandlers so the
* MCP proxy can call them via POST /mcp/execute.
*/
type MemorySearchArgs = {
query?: string;
maxResults?: number;
minScore?: number;
corpus?: "memory" | "wiki" | "all";
};
type MemoryGetArgs = {
path?: string;
from?: number;
lines?: number;
corpus?: "memory" | "wiki" | "all";
};
/** Collect all memory files in a workspace. */
function collectMemoryFiles(workspace: string): string[] {
const files: string[] = [];
const memoryMd = path.join(workspace, "MEMORY.md");
if (fs.existsSync(memoryMd)) files.push(memoryMd);
const memoryDir = path.join(workspace, "memory");
if (fs.existsSync(memoryDir)) {
for (const f of fs.readdirSync(memoryDir)) {
if (f.endsWith(".md")) files.push(path.join(memoryDir, f));
}
}
return files;
}
/** Score a file's relevance to a query by counting keyword occurrences. */
function scoreFile(content: string, query: string): { score: number; snippets: string[] } {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const lines = content.split("\n");
const snippets: string[] = [];
let score = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].toLowerCase();
const matches = words.filter((w) => line.includes(w)).length;
if (matches > 0) {
score += matches;
// Include surrounding context (1 line before/after)
const start = Math.max(0, i - 1);
const end = Math.min(lines.length - 1, i + 1);
const snippet = lines.slice(start, end + 1).join("\n").trim();
if (snippet && !snippets.includes(snippet)) {
snippets.push(snippet);
}
}
}
return { score, snippets };
}
export function makeMemorySearchHandler(workspace: string) {
return async (args: unknown): Promise<string> => {
const { query = "", maxResults = 5, corpus = "memory" } = args as MemorySearchArgs;
if (corpus === "wiki") {
return JSON.stringify({ disabled: true, reason: "wiki corpus not available in contractor bridge" });
}
const files = collectMemoryFiles(workspace);
if (files.length === 0) {
return JSON.stringify({
results: [],
note: "No memory files found in workspace. Memory is empty.",
});
}
const results: Array<{ file: string; score: number; snippets: string[] }> = [];
for (const filePath of files) {
try {
const content = fs.readFileSync(filePath, "utf8");
const { score, snippets } = scoreFile(content, query);
if (score > 0) {
results.push({
file: path.relative(workspace, filePath),
score,
snippets,
});
}
} catch {
// skip unreadable files
}
}
results.sort((a, b) => b.score - a.score);
const top = results.slice(0, maxResults);
if (top.length === 0) {
return JSON.stringify({
results: [],
note: `No memory matches found for query: "${query}"`,
});
}
return JSON.stringify({ results: top });
};
}
export function makeMemoryGetHandler(workspace: string) {
return async (args: unknown): Promise<string> => {
const { path: filePath = "", from, lines, corpus = "memory" } = args as MemoryGetArgs;
if (corpus === "wiki") {
return "[memory_get] wiki corpus not available in contractor bridge";
}
if (!filePath) return "[memory_get] path is required";
// Resolve path relative to workspace (prevent path traversal)
const resolved = path.resolve(workspace, filePath);
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
return `[memory_get] path outside workspace: ${filePath}`;
}
if (!fs.existsSync(resolved)) {
return `[memory_get] file not found: ${filePath}`;
}
try {
const content = fs.readFileSync(resolved, "utf8");
const allLines = content.split("\n");
const startLine = from != null ? Math.max(0, from - 1) : 0; // convert 1-based to 0-based
const endLine = lines != null ? startLine + lines : allLines.length;
return allLines.slice(startLine, endLine).join("\n");
} catch (err) {
return `[memory_get] error reading file: ${String(err)}`;
}
};
}
/**
* Register memory tool handlers into the shared globalThis tool registry.
* Called by the bridge server on startup.
*/
export function registerMemoryHandlers(workspace: string): void {
const _G = globalThis as Record<string, unknown>;
if (!(_G["_contractorToolHandlers"] instanceof Map)) {
_G["_contractorToolHandlers"] = new Map<string, (args: unknown) => Promise<string>>();
}
const registry = _G["_contractorToolHandlers"] as Map<string, (args: unknown) => Promise<string>>;
registry.set("memory_search", makeMemorySearchHandler(workspace));
registry.set("memory_get", makeMemoryGetHandler(workspace));
}

384
plugin/web/server.ts Normal file
View File

@@ -0,0 +1,384 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import type { BridgeInboundRequest } from "../core/types/model.js";
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
import { buildBootstrap } from "./bootstrap.js";
import { dispatchToClaude } from "../core/claude/sdk-adapter.js";
import { dispatchToGemini } from "../core/gemini/sdk-adapter.js";
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
import {
getSession,
putSession,
markOrphaned,
} from "../core/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;
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>;
}
const _G = globalThis as Record<string, unknown>;
export function createBridgeServer(config: BridgeServerConfig): http.Server {
const { port, apiKey, permissionMode, resolveAgent, 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, workspaceContextFiles } = 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";
}
// Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude
const isGemini = typeof body.model === "string" && body.model.includes("gemini");
// Look up existing session (shared structure for both Claude and Gemini)
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
let resumeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
// Bootstrap is passed as the system prompt on every turn (stateless — not persisted in session files).
// For Claude: --system-prompt fully replaces any prior system prompt each invocation.
// For Gemini: written to workspace/GEMINI.md, read dynamically by Gemini CLI each turn.
// This keeps persona and skills current without needing to track first-turn state.
const systemPrompt = buildBootstrap({
agentId,
openclawSessionKey: sessionKey,
workspace,
skillsBlock: skillsBlock || undefined,
workspaceContextFiles,
});
// 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 {
const dispatchIter = isGemini
? dispatchToGemini({
prompt: latestMessage,
systemPrompt,
workspace,
agentId,
resumeSessionId: resumeSessionId ?? undefined,
openclawTools,
bridgePort: port,
bridgeApiKey: apiKey,
})
: dispatchToClaude({
prompt: latestMessage,
systemPrompt,
workspace,
agentId,
resumeSessionId: resumeSessionId ?? undefined,
permissionMode,
openclawTools,
bridgePort: port,
bridgeApiKey: apiKey,
});
for await (const event of dispatchIter) {
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] ${isGemini ? "gemini" : "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 (shared for both Claude and Gemini)
if (newSessionId && sessionKey && !hasError) {
const now = new Date().toISOString();
putSession(workspace, {
openclawSessionKey: sessionKey,
agentId,
contractor: isGemini ? "gemini" : "claude",
claudeSessionId: newSessionId,
workspace,
createdAt: existingEntry?.createdAt ?? now,
lastActivityAt: now,
state: "active",
});
logger.info(
`[contractor-bridge] session mapped sessionKey=${sessionKey} contractor=${isGemini ? "gemini" : "claude"} sessionId=${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",
},
{
id: "contractor-gemini-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;
}
// Debug: list registered tools in the global plugin registry
if (method === "GET" && url === "/debug/tools") {
const pluginRegistry = getGlobalPluginRegistry();
const liveConfig = _G["_contractorOpenClawConfig"] ?? null;
if (!pluginRegistry) {
sendJson(res, 200, { error: "registry not available" });
} else {
sendJson(res, 200, {
configAvailable: liveConfig !== null,
tools: pluginRegistry.tools.map((t) => ({ names: t.names, pluginId: t.pluginId })),
});
}
return;
}
// MCP proxy tool execution callback.
// Called by services/openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
// Relays the call to OpenClaw's actual tool implementation via the global plugin registry.
if (method === "POST" && url === "/mcp/execute") {
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
try {
body = await parseBodyRaw(req) as { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
} catch {
sendJson(res, 400, { error: "invalid_json" });
return;
}
const toolName = body.tool ?? "";
const toolArgs = body.args ?? {};
const execWorkspace = body.workspace ?? "";
const execAgentId = body.agentId ?? "";
logger.info(`[contractor-bridge] mcp/execute tool=${toolName} workspace=${execWorkspace} agentId=${execAgentId}`);
// Relay to OpenClaw's actual tool implementation via the global plugin registry.
// This avoids reimplementing tool logic in the bridge — we call the real tool factory.
try {
const pluginRegistry = getGlobalPluginRegistry();
if (!pluginRegistry) {
sendJson(res, 200, { error: `[mcp/execute] plugin registry not available (tool=${toolName})` });
return;
}
// Find the tool registration by name
const toolReg = pluginRegistry.tools.find((t) => t.names.includes(toolName));
if (!toolReg) {
sendJson(res, 200, { error: `Tool '${toolName}' not registered in OpenClaw plugin registry` });
return;
}
// Build tool execution context.
// config is required by memory tools (resolveMemoryToolContext checks it).
// Read from globalThis where index.ts stores api.config on every registration.
// sessionKey must be in OpenClaw's canonical format "agent:<agentId>:<rest>" so that
// parseAgentSessionKey() can extract the agentId for memory tool context resolution.
const liveConfig = (_G["_contractorOpenClawConfig"] ?? null) as Record<string, unknown> | null;
// Construct a canonical session key so memory tools can resolve the agentId from it.
// Format: "agent:<agentId>:direct:bridge"
const canonicalSessionKey = execAgentId ? `agent:${execAgentId}:direct:bridge` : undefined;
const toolCtx = {
config: liveConfig ?? undefined,
workspaceDir: execWorkspace || undefined,
agentDir: execWorkspace || undefined,
agentId: execAgentId || undefined,
sessionKey: canonicalSessionKey,
};
// Instantiate the tool via its factory
const toolOrTools = toolReg.factory(toolCtx);
const toolInstance = Array.isArray(toolOrTools)
? toolOrTools.find((t) => (t as { name?: string }).name === toolName)
: toolOrTools;
if (!toolInstance || typeof (toolInstance as { execute?: unknown }).execute !== "function") {
sendJson(res, 200, { error: `Tool '${toolName}' factory returned no executable tool` });
return;
}
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
const toolResult = await execFn(randomUUID(), toolArgs);
// Extract text content from AgentToolResult
const text = (toolResult.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
sendJson(res, 200, { result: text || "(no result)" });
} catch (err) {
logger.warn(`[contractor-bridge] mcp/execute error tool=${toolName}: ${String(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;
}

183
scripts/install.mjs Normal file
View File

@@ -0,0 +1,183 @@
#!/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 });
// Copy plugin/ contents to the install root.
// plugin/ contains index.ts, openclaw.plugin.json, package.json, and subdirs.
const pluginDir = path.join(PROJECT_ROOT, "plugin");
fs.cpSync(pluginDir, PLUGIN_INSTALL_DIR, { recursive: true });
// Copy services/ (standalone processes used by the plugin) into the install root.
const servicesDir = path.join(PROJECT_ROOT, "services");
if (fs.existsSync(servicesDir)) {
fs.cpSync(servicesDir, path.join(PLUGIN_INSTALL_DIR, "services"), { recursive: true });
}
// 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,
},
{
id: "contractor-gemini-bridge",
name: "Contractor Gemini 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);
}

View File

@@ -0,0 +1,143 @@
#!/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 ?? "";
const workspace = process.env.WORKSPACE ?? "";
const agentId = process.env.AGENT_ID ?? "";
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}`,
},
// Include workspace and agentId so bridge can build the correct tool execution context
body: JSON.stringify({ tool: name, args, workspace, agentId }),
});
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) => {
const originName = t.function.name;
const baseDesc = t.function.description ?? "";
const aliasNote = `[openclaw tool: ${originName}] If any skill or instruction refers to "${originName}", this is the tool to call.`;
const description = baseDesc ? `${baseDesc}\n${aliasNote}` : aliasNote;
return {
name: originName,
description,
inputSchema: t.function.parameters ?? { type: "object", properties: {} },
};
});
sendResult(id, { tools });
return;
}
if (method === "tools/call") {
const toolName = params.name;
const toolArgs = params.arguments ?? {};
try {
const result = await executeTool(toolName, toolArgs);
sendResult(id, {
content: [{ type: "text", text: result }],
isError: false,
});
} catch (err) {
sendResult(id, {
content: [{ type: "text", text: `[mcp-proxy] ${String(err)}` }],
isError: true,
});
}
return;
}
sendError(id, -32601, `Method not found: ${method}`);
}
// ── Main loop ─────────────────────────────────────────────────────────────────
rl.on("line", async (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let msg;
try {
msg = JSON.parse(trimmed);
} catch {
sendError(null, -32700, "Parse error");
return;
}
await handleRequest(msg);
});

View File

@@ -0,0 +1,54 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
const _G = globalThis as Record<string, unknown>;
const LIFECYCLE_KEY = "_contractorProbeLifecycleRegistered";
const SIDECAR_KEY = "_contractorProbeSidecarProcess";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function startSidecar(logger: OpenClawPluginApi["logger"]): void {
const serverPath = path.join(__dirname, "server.mjs");
const child = spawn(process.execPath, [serverPath], {
env: { ...process.env, PROBE_PORT: "8799" },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (d: Buffer) =>
logger.info(`[contractor-probe-sidecar] ${d.toString().trim()}`)
);
child.stderr?.on("data", (d: Buffer) =>
logger.warn(`[contractor-probe-sidecar] ${d.toString().trim()}`)
);
child.on("exit", (code: number | null) =>
logger.info(`[contractor-probe-sidecar] exited code=${code}`)
);
_G[SIDECAR_KEY] = child;
logger.info("[contractor-probe] sidecar started");
}
function stopSidecar(logger: OpenClawPluginApi["logger"]): void {
const child = _G[SIDECAR_KEY] as import("node:child_process").ChildProcess | undefined;
if (child && !child.killed) {
child.kill("SIGTERM");
logger.info("[contractor-probe] sidecar stopped");
}
}
export default {
id: "contractor-probe",
name: "Contractor Probe",
register(api: OpenClawPluginApi) {
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;
startSidecar(api.logger);
api.on("gateway_stop", () => stopSidecar(api.logger));
}
api.logger.info("[contractor-probe] plugin registered");
},
};

View File

@@ -0,0 +1,8 @@
{
"id": "contractor-probe",
"name": "Contractor Probe",
"version": "0.1.0",
"description": "Test plugin to inspect custom model provider payloads",
"main": "index.ts",
"configSchema": {}
}

View File

@@ -0,0 +1,6 @@
{
"name": "contractor-probe",
"version": "0.1.0",
"type": "module",
"main": "index.ts"
}

View File

@@ -0,0 +1,166 @@
/**
* contractor-probe sidecar server
*
* Acts as an OpenAI-compatible model provider for "contractor-probe-bridge".
* Every request is logged in full to /tmp/contractor-probe-requests.jsonl so
* we can inspect exactly what OpenClaw sends to a custom model provider.
* The response echoes back a JSON summary of what was received.
*/
import http from "node:http";
import fs from "node:fs";
const PORT = Number(process.env.PROBE_PORT || 8799);
const LOG_FILE = process.env.PROBE_LOG || "/tmp/contractor-probe-requests.jsonl";
const MODEL_ID = "contractor-probe-bridge";
function sendJson(res, status, payload) {
const body = JSON.stringify(payload, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function logRequest(entry) {
try {
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
} catch (err) {
console.error("[contractor-probe] failed to write log:", err);
}
}
function buildChatCompletionResponse(reqBody, summary) {
return {
id: `chatcmpl_probe_${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: reqBody?.model || MODEL_ID,
choices: [
{
index: 0,
message: {
role: "assistant",
content: `[contractor-probe] Received request:\n${JSON.stringify(summary, null, 2)}`,
},
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 10, total_tokens: 10 },
};
}
function buildResponsesResponse(reqBody, summary) {
return {
id: `resp_probe_${Date.now()}`,
object: "response",
created_at: Math.floor(Date.now() / 1000),
model: reqBody?.model || MODEL_ID,
output: [
{
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: `[contractor-probe] Received request:\n${JSON.stringify(summary, null, 2)}`,
},
],
},
],
usage: { input_tokens: 0, output_tokens: 10, total_tokens: 10 },
};
}
function listModels() {
return {
object: "list",
data: [
{
id: MODEL_ID,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "contractor-probe",
},
],
};
}
const server = http.createServer((req, res) => {
const url = req.url ?? "/";
const method = req.method ?? "GET";
if (method === "GET" && url === "/health") {
return sendJson(res, 200, { ok: true, service: "contractor-probe", port: PORT });
}
if (method === "GET" && url === "/v1/models") {
return sendJson(res, 200, listModels());
}
if (method !== "POST") {
return sendJson(res, 404, { error: "not_found" });
}
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 2_000_000) req.destroy();
});
req.on("end", () => {
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch {
return sendJson(res, 400, { error: "invalid_json" });
}
const entry = {
ts: new Date().toISOString(),
method,
url,
headers: req.headers,
body: parsed,
};
logRequest(entry);
console.log(`[contractor-probe] ${method} ${url}${new Date().toISOString()}`);
console.log(`[contractor-probe] messages count: ${parsed?.messages?.length ?? 0}`);
if (parsed?.messages?.length) {
const last = parsed.messages[parsed.messages.length - 1];
console.log(`[contractor-probe] last message role=${last.role} content=${JSON.stringify(last.content)?.substring(0, 200)}`);
}
// Build a summary to echo back in the response
const summary = {
model: parsed.model,
messagesCount: parsed.messages?.length ?? 0,
lastMessage: parsed.messages?.[parsed.messages.length - 1] ?? null,
firstSystemContent: parsed.messages?.find(m => m.role === "system")?.content?.toString()?.substring(0, 300) ?? null,
stream: parsed.stream ?? false,
temperature: parsed.temperature,
max_tokens: parsed.max_tokens,
};
if (url === "/v1/chat/completions") {
return sendJson(res, 200, buildChatCompletionResponse(parsed, summary));
}
if (url === "/v1/responses") {
return sendJson(res, 200, buildResponsesResponse(parsed, summary));
}
return sendJson(res, 404, { error: "not_found" });
});
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[contractor-probe] listening on 127.0.0.1:${PORT}`);
console.log(`[contractor-probe] logging requests to ${LOG_FILE}`);
});
process.on("SIGTERM", () => {
console.log("[contractor-probe] shutting down");
server.close(() => process.exit(0));
});

17
tsconfig.json Normal file
View File

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