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>
This commit is contained in:
101
plugin/index.ts
Normal file
101
plugin/index.ts
Normal 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})`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user