diff --git a/docs/TEST_FLOW.md b/docs/TEST_FLOW.md new file mode 100644 index 0000000..b62b9ea --- /dev/null +++ b/docs/TEST_FLOW.md @@ -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 ` 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 `. + +--- + +### 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 + + + contractor-test-skill + Test skill for verifying that a contractor agent can discover and invoke a workspace script… + /home/hzhang/.openclaw/skills/contractor-test-skill + + +``` + +**Request**: user message: `"Run the contractor test skill and show me the output."` + +**Expected**: +- Agent reads `SKILL.md` at the given `` +- Expands `{baseDir}` → `/home/hzhang/.openclaw/skills/contractor-test-skill` +- Executes `scripts/test.sh` via Bash +- Response contains: + ``` + === contractor-test-skill: PASSED === + Timestamp: + ``` + +**Why first turn matters**: the bootstrap embeds the `` 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 ``) 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. diff --git a/docs/claude/BRIDGE_MODEL_FINDINGS.md b/docs/claude/BRIDGE_MODEL_FINDINGS.md new file mode 100644 index 0000000..97b1ef1 --- /dev/null +++ b/docs/claude/BRIDGE_MODEL_FINDINGS.md @@ -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] +``` + +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//.jsonl +``` + +`` 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 "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) diff --git a/package.json b/package.json index 367bd72..ebef94a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent", "type": "module", - "main": "src/index.ts", + "main": "plugin/index.ts", "scripts": { "check": "tsc --noEmit", "install-plugin": "node scripts/install.mjs --install", diff --git a/src/cli/contractor-agents-add.ts b/plugin/commands/contractor-agents-add.ts similarity index 53% rename from src/cli/contractor-agents-add.ts rename to plugin/commands/contractor-agents-add.ts index 2a07d0d..103aa1b 100644 --- a/src/cli/contractor-agents-add.ts +++ b/plugin/commands/contractor-agents-add.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; -import { createBaseAgent } from "../openclaw/agents-add-runner.js"; -import { markAgentAsClaudeContractor } from "../openclaw/agent-config-writer.js"; -import { ensureContractorStateDir } from "../contractor/runtime-state.js"; -import { initEmptySessionMap } from "../contractor/session-map-store.js"; +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; @@ -17,24 +17,29 @@ export async function runContractorAgentsAdd(args: AddArgs): Promise { if (!agentId) throw new Error("--agent-id is required"); if (!workspace) throw new Error("--workspace is required"); if (!contractor) throw new Error("--contractor is required"); - if (contractor !== "claude") { - throw new Error(`--contractor ${contractor}: only 'claude' is supported in phase 1`); + 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: "contractor-agent/contractor-claude-bridge", - }); + createBaseAgent({ agentId, workspace, bridgeModel }); console.log(`[contractor-agent] Writing contractor metadata`); - markAgentAsClaudeContractor(agentId, workspace); + if (contractor === "gemini") { + markAgentAsGeminiContractor(agentId, workspace); + } else { + markAgentAsClaudeContractor(agentId, workspace); + } console.log(`[contractor-agent] Initializing runtime state`); ensureContractorStateDir(workspace); @@ -43,6 +48,6 @@ export async function runContractorAgentsAdd(args: AddArgs): Promise { console.log(`[contractor-agent] Done.`); console.log(` Agent: ${agentId}`); console.log(` Workspace: ${workspace}`); - console.log(` Model: contractor-agent/contractor-claude-bridge`); + console.log(` Model: ${bridgeModel}`); console.log(` State dir: ${workspace}/.openclaw/contractor-agent/`); } diff --git a/src/cli/register-cli.ts b/plugin/commands/register-cli.ts similarity index 100% rename from src/cli/register-cli.ts rename to plugin/commands/register-cli.ts diff --git a/src/claude/sdk-adapter.ts b/plugin/core/claude/sdk-adapter.ts similarity index 90% rename from src/claude/sdk-adapter.ts rename to plugin/core/claude/sdk-adapter.ts index 4e1f6a0..a547916 100644 --- a/src/claude/sdk-adapter.ts +++ b/plugin/core/claude/sdk-adapter.ts @@ -17,6 +17,8 @@ export type OpenAITool = { 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; @@ -30,8 +32,10 @@ export type ClaudeDispatchOptions = { }; // 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, "../mcp/openclaw-mcp-server.mjs"); +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 @@ -89,6 +93,7 @@ export async function* dispatchToClaude( ): AsyncIterable { const { prompt, + systemPrompt, workspace, agentId = "", resumeSessionId, @@ -110,6 +115,13 @@ export async function* dispatchToClaude( "--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); } diff --git a/src/contractor/metadata-resolver.ts b/plugin/core/contractor/metadata-resolver.ts similarity index 100% rename from src/contractor/metadata-resolver.ts rename to plugin/core/contractor/metadata-resolver.ts diff --git a/src/contractor/runtime-state.ts b/plugin/core/contractor/runtime-state.ts similarity index 100% rename from src/contractor/runtime-state.ts rename to plugin/core/contractor/runtime-state.ts diff --git a/src/contractor/session-map-store.ts b/plugin/core/contractor/session-map-store.ts similarity index 93% rename from src/contractor/session-map-store.ts rename to plugin/core/contractor/session-map-store.ts index aa55064..d15fd16 100644 --- a/src/contractor/session-map-store.ts +++ b/plugin/core/contractor/session-map-store.ts @@ -8,7 +8,8 @@ function readFile(workspace: string): SessionMapFile { return { version: 1, sessions: [] }; } try { - return JSON.parse(fs.readFileSync(p, "utf8")) as SessionMapFile; + const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as Partial; + return { version: parsed.version ?? 1, sessions: parsed.sessions ?? [] }; } catch { return { version: 1, sessions: [] }; } diff --git a/plugin/core/gemini/sdk-adapter.ts b/plugin/core/gemini/sdk-adapter.ts new file mode 100644 index 0000000..11cd6e2 --- /dev/null +++ b/plugin/core/gemini/sdk-adapter.ts @@ -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 = {}; + if (fs.existsSync(settingsPath)) { + try { + existing = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record; + } 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__). + // 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 { + 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; + 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((resolve) => { + resolveNext = resolve; + }); + } + + while (events.length > 0) { + yield events.shift()!; + } + + await new Promise((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}` : ""}`, + }; + } +} diff --git a/src/openclaw/agent-config-writer.ts b/plugin/core/openclaw/agent-config-writer.ts similarity index 67% rename from src/openclaw/agent-config-writer.ts rename to plugin/core/openclaw/agent-config-writer.ts index 989a5b9..80ab6af 100644 --- a/src/openclaw/agent-config-writer.ts +++ b/plugin/core/openclaw/agent-config-writer.ts @@ -5,7 +5,8 @@ import path from "node:path"; type OpenClawConfig = Record; type AgentEntry = Record; -const CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge"; +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"); @@ -25,25 +26,33 @@ function writeConfig(configPath: string, config: OpenClawConfig): void { * We intentionally do NOT write a custom `runtime.type` — OpenClaw's schema only * allows "embedded" or "acp", and contractor agents are identified by their model. */ -export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void { - const { config, configPath } = readConfig(); - +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 !== CONTRACTOR_MODEL) { + if (agent.model !== expectedModel) { throw new Error( - `agent ${agentId} model is "${String(agent.model)}", expected "${CONTRACTOR_MODEL}"`, + `agent ${agentId} model is "${String(agent.model)}", expected "${expectedModel}"`, ); } - // Ensure tools are enabled for this agent so OpenClaw includes tool definitions - // in every chat completions request — needed for the MCP proxy to expose them to Claude. + // 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); +} diff --git a/src/openclaw/agents-add-runner.ts b/plugin/core/openclaw/agents-add-runner.ts similarity index 100% rename from src/openclaw/agents-add-runner.ts rename to plugin/core/openclaw/agents-add-runner.ts diff --git a/src/types/contractor.ts b/plugin/core/types/contractor.ts similarity index 87% rename from src/types/contractor.ts rename to plugin/core/types/contractor.ts index 444db86..d3941a1 100644 --- a/src/types/contractor.ts +++ b/plugin/core/types/contractor.ts @@ -1,8 +1,8 @@ -export type ContractorKind = "claude"; +export type ContractorKind = "claude" | "gemini"; export type ContractorAgentMetadata = { agentId: string; - contractor: "claude"; + contractor: ContractorKind; bridgeModel: string; workspace: string; permissionMode: string; diff --git a/src/types/model.ts b/plugin/core/types/model.ts similarity index 100% rename from src/types/model.ts rename to plugin/core/types/model.ts diff --git a/src/types/session-map.ts b/plugin/core/types/session-map.ts similarity index 75% rename from src/types/session-map.ts rename to plugin/core/types/session-map.ts index 8af557d..26d7316 100644 --- a/src/types/session-map.ts +++ b/plugin/core/types/session-map.ts @@ -3,7 +3,8 @@ export type SessionMapState = "active" | "closed" | "orphaned"; export type SessionMapEntry = { openclawSessionKey: string; agentId: string; - contractor: "claude"; + contractor: "claude" | "gemini"; + /** Opaque session ID used to resume the underlying CLI session (--resume). */ claudeSessionId: string; workspace: string; createdAt: string; diff --git a/src/index.ts b/plugin/index.ts similarity index 93% rename from src/index.ts rename to plugin/index.ts index cedc96d..ab6b2b6 100644 --- a/src/index.ts +++ b/plugin/index.ts @@ -2,10 +2,10 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { normalizePluginConfig } from "./types/contractor.js"; -import { resolveContractorAgentMetadata } from "./contractor/metadata-resolver.js"; -import { createBridgeServer } from "./bridge/server.js"; -import { registerCli } from "./cli/register-cli.js"; +import { 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 { diff --git a/openclaw.plugin.json b/plugin/openclaw.plugin.json similarity index 96% rename from openclaw.plugin.json rename to plugin/openclaw.plugin.json index 38ab2d2..7e5225c 100644 --- a/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -3,7 +3,7 @@ "name": "Contractor Agent", "version": "0.1.0", "description": "Turns Claude Code into an OpenClaw-managed contractor agent", - "main": "src/index.ts", + "main": "index.ts", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..7d533c2 --- /dev/null +++ b/plugin/package.json @@ -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": "*" + } +} diff --git a/plugin/web/bootstrap.ts b/plugin/web/bootstrap.ts new file mode 100644 index 0000000..011efed --- /dev/null +++ b/plugin/web/bootstrap.ts @@ -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/.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 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"); +} diff --git a/src/bridge/input-filter.ts b/plugin/web/input-filter.ts similarity index 97% rename from src/bridge/input-filter.ts rename to plugin/web/input-filter.ts index f9875ce..acee420 100644 --- a/src/bridge/input-filter.ts +++ b/plugin/web/input-filter.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import type { BridgeInboundRequest, OpenAIMessage } from "../types/model.js"; +import type { BridgeInboundRequest, OpenAIMessage } from "../core/types/model.js"; function messageText(m: OpenAIMessage): string { if (typeof m.content === "string") return m.content; diff --git a/src/bridge/memory-handlers.ts b/plugin/web/memory-handlers.ts similarity index 100% rename from src/bridge/memory-handlers.ts rename to plugin/web/memory-handlers.ts diff --git a/src/bridge/server.ts b/plugin/web/server.ts similarity index 82% rename from src/bridge/server.ts rename to plugin/web/server.ts index 23aa7e2..167a95c 100644 --- a/src/bridge/server.ts +++ b/plugin/web/server.ts @@ -1,15 +1,16 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; -import type { BridgeInboundRequest } from "../types/model.js"; +import type { BridgeInboundRequest } from "../core/types/model.js"; import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js"; import { buildBootstrap } from "./bootstrap.js"; -import { dispatchToClaude } from "../claude/sdk-adapter.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 "../contractor/session-map-store.js"; +} from "../core/contractor/session-map-store.js"; export type BridgeServerConfig = { port: number; @@ -129,15 +130,24 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { workspace = "/tmp"; } - // Look up existing Claude session - let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null; - let claudeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null; + // Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude + const isGemini = typeof body.model === "string" && body.model.includes("gemini"); - // Build prompt: bootstrap on first turn, bare message on subsequent turns - const isFirstTurn = !claudeSessionId; - const prompt = isFirstTurn - ? `${buildBootstrap({ agentId, openclawSessionKey: sessionKey, workspace, skillsBlock: skillsBlock || undefined, workspaceContextFiles })}\n\n---\n\n${latestMessage}` - : latestMessage; + // 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, { @@ -154,22 +164,36 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { const openclawTools = body.tools ?? []; try { - for await (const event of dispatchToClaude({ - prompt, - workspace, - agentId, - resumeSessionId: claudeSessionId ?? undefined, - permissionMode, - openclawTools, // pass every turn — MCP server exits with the claude process - bridgePort: port, - bridgeApiKey: apiKey, - })) { + 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] claude error: ${event.message}`); + logger.warn(`[contractor-bridge] ${isGemini ? "gemini" : "claude"} error: ${event.message}`); hasError = true; sseWrite(res, buildChunk(completionId, `[contractor-bridge error: ${event.message}]`)); } @@ -184,13 +208,13 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { sseWrite(res, "[DONE]"); res.end(); - // Persist session mapping + // Persist session mapping (shared for both Claude and Gemini) if (newSessionId && sessionKey && !hasError) { const now = new Date().toISOString(); putSession(workspace, { openclawSessionKey: sessionKey, agentId, - contractor: "claude", + contractor: isGemini ? "gemini" : "claude", claudeSessionId: newSessionId, workspace, createdAt: existingEntry?.createdAt ?? now, @@ -198,7 +222,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { state: "active", }); logger.info( - `[contractor-bridge] session mapped sessionKey=${sessionKey} claudeSessionId=${newSessionId}`, + `[contractor-bridge] session mapped sessionKey=${sessionKey} contractor=${isGemini ? "gemini" : "claude"} sessionId=${newSessionId}`, ); } else if (hasError && sessionKey && existingEntry) { markOrphaned(workspace, sessionKey); @@ -233,6 +257,12 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { 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; @@ -266,7 +296,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server { } // MCP proxy tool execution callback. - // Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool. + // 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; workspace?: string; agentId?: string }; diff --git a/scripts/install.mjs b/scripts/install.mjs index 301884f..5e7c1ab 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -46,31 +46,16 @@ function install() { } fs.mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true }); - // OpenClaw expects the plugin entry (index.ts) at the plugin root, - // matching the convention used by existing plugins like dirigent. - // We flatten src/ to the plugin root and copy supporting files alongside it. + // 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 }); - // Flatten src/ → plugin root - const srcDir = path.join(PROJECT_ROOT, "src"); - fs.cpSync(srcDir, PLUGIN_INSTALL_DIR, { recursive: true }); - - // Copy package.json (update main to point to index.ts at root) - const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8")); - pkg.main = "index.ts"; - fs.writeFileSync( - path.join(PLUGIN_INSTALL_DIR, "package.json"), - JSON.stringify(pkg, null, 2) + "\n", - ); - - // Copy openclaw.plugin.json — set main to root index.ts - const manifest = JSON.parse( - fs.readFileSync(path.join(PROJECT_ROOT, "openclaw.plugin.json"), "utf8"), - ); - manifest.main = "index.ts"; - fs.writeFileSync( - path.join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"), - JSON.stringify(manifest, null, 2) + "\n", - ); + // 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...`); @@ -99,6 +84,15 @@ function install() { 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, + }, ], }; diff --git a/src/mcp/openclaw-mcp-server.mjs b/services/openclaw-mcp-server.mjs similarity index 100% rename from src/mcp/openclaw-mcp-server.mjs rename to services/openclaw-mcp-server.mjs diff --git a/src/bridge/bootstrap.ts b/src/bridge/bootstrap.ts deleted file mode 100644 index 3f1bc8c..0000000 --- a/src/bridge/bootstrap.ts +++ /dev/null @@ -1,87 +0,0 @@ -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[]; -}; - -/** - * Build the one-time bootstrap message injected at the start of a new Claude session. - * This tells Claude it is operating as an OpenClaw contractor agent. - * Must NOT be re-injected on every turn. - */ -export function buildBootstrap(input: BootstrapInput): string { - const contextFiles = input.workspaceContextFiles ?? []; - const hasSoul = contextFiles.includes("SOUL.md"); - const hasIdentity = contextFiles.includes("IDENTITY.md"); - - 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.`, - ]; - - // Inject persona/identity context from OpenClaw workspace files. - // These files define who this agent is and how it should behave. - if (hasSoul || hasIdentity) { - lines.push(``, `## Persona & Identity`); - if (hasSoul) { - lines.push( - `Read ${input.workspace}/SOUL.md now (using the Read tool) and embody the persona and tone it describes.`, - `SOUL.md defines your values, communication style, and boundaries — treat it as authoritative.`, - ); - } - if (hasIdentity) { - lines.push( - `Read ${input.workspace}/IDENTITY.md now (using the Read tool) to understand your name, creature type, and personal vibe.`, - `If IDENTITY.md is empty/template-only, you may fill it in as you develop your identity.`, - ); - } - } - - // Memory system note: OpenClaw uses workspace/memory/*.md + MEMORY.md. - // Claude Code also maintains per-project auto-memory at ~/.claude/projects//memory/. - // Both are active; Claude Code's auto-memory is loaded automatically each session. - // OpenClaw memory tools (memory_search, memory_get) are available as MCP tools - // but require bridge-side implementations to execute (not yet wired). - if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) { - lines.push( - ``, - `## Memory`, - `OpenClaw memory files exist in ${input.workspace}/memory/ and ${input.workspace}/MEMORY.md.`, - `Use the Read tool to access these directly. For writing new memories, write to ${input.workspace}/memory/.md.`, - ); - } - - 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 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"); -} diff --git a/test/contractor-probe/index.ts b/test/contractor-probe/index.ts new file mode 100644 index 0000000..3f12bdd --- /dev/null +++ b/test/contractor-probe/index.ts @@ -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; +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"); + }, +}; diff --git a/test/contractor-probe/openclaw.plugin.json b/test/contractor-probe/openclaw.plugin.json new file mode 100644 index 0000000..7e1b28e --- /dev/null +++ b/test/contractor-probe/openclaw.plugin.json @@ -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": {} +} diff --git a/test/contractor-probe/package.json b/test/contractor-probe/package.json new file mode 100644 index 0000000..f97e2c6 --- /dev/null +++ b/test/contractor-probe/package.json @@ -0,0 +1,6 @@ +{ + "name": "contractor-probe", + "version": "0.1.0", + "type": "module", + "main": "index.ts" +} diff --git a/test/contractor-probe/server.mjs b/test/contractor-probe/server.mjs new file mode 100644 index 0000000..cae57c8 --- /dev/null +++ b/test/contractor-probe/server.mjs @@ -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)); +}); diff --git a/tsconfig.json b/tsconfig.json index b95e02d..c427aa8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,11 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src", + "rootDir": "plugin", "declaration": true, "declarationMap": true, "sourceMap": true }, - "include": ["src/**/*"], + "include": ["plugin/**/*"], "exclude": ["node_modules", "dist", "test", "scripts"] }