Merge pull request #2607 from code-yeongyu/feat/openclaw-integration

feat: implement OpenClaw integration
This commit is contained in:
YeonGyu-Kim
2026-03-16 17:48:11 +09:00
committed by GitHub
14 changed files with 1010 additions and 0 deletions

View File

@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
"anthropic-effort",
"hashline-read-enhancer",
"read-image-resizer",
"openclaw-sender",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { NotificationConfigSchema } from "./notification"
import { OpenClawConfigSchema } from "./openclaw"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
import { SkillsConfigSchema } from "./skills"
@@ -55,6 +56,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
openclaw: OpenClawConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
export const OpenClawHookEventSchema = z.enum([
"session-start",
"session-end",
"session-idle",
"ask-user-question",
"stop",
]);
export const OpenClawHttpGatewayConfigSchema = z.object({
type: z.literal("http").optional(),
url: z.string(), // Allow looser URL validation as it might contain placeholders
headers: z.record(z.string(), z.string()).optional(),
method: z.enum(["POST", "PUT"]).optional(),
timeout: z.number().optional(),
});
export const OpenClawCommandGatewayConfigSchema = z.object({
type: z.literal("command"),
command: z.string(),
timeout: z.number().optional(),
});
export const OpenClawGatewayConfigSchema = z.union([
OpenClawHttpGatewayConfigSchema,
OpenClawCommandGatewayConfigSchema,
]);
export const OpenClawHookMappingSchema = z.object({
gateway: z.string(),
instruction: z.string(),
enabled: z.boolean(),
});
export const OpenClawConfigSchema = z.object({
enabled: z.boolean(),
gateways: z.record(z.string(), OpenClawGatewayConfigSchema),
hooks: z
.object({
"session-start": OpenClawHookMappingSchema.optional(),
"session-end": OpenClawHookMappingSchema.optional(),
"session-idle": OpenClawHookMappingSchema.optional(),
"ask-user-question": OpenClawHookMappingSchema.optional(),
stop: OpenClawHookMappingSchema.optional(),
})
.strict()
.optional(),
});
export type OpenClawConfig = z.infer<typeof OpenClawConfigSchema>;

View File

@@ -0,0 +1,70 @@
import { wakeOpenClaw } from "../../openclaw/client";
import type { OpenClawConfig, OpenClawContext } from "../../openclaw/types";
import { getMainSessionID } from "../../features/claude-code-session-state";
import type { PluginContext } from "../../plugin/types";
export function createOpenClawSenderHook(
ctx: PluginContext,
config: OpenClawConfig
) {
return {
event: async (input: {
event: { type: string; properties?: Record<string, unknown> };
}) => {
const { type, properties } = input.event;
const info = properties?.info as Record<string, unknown> | undefined;
const context: OpenClawContext = {
sessionId:
(properties?.sessionID as string) ||
(info?.id as string) ||
getMainSessionID(),
projectPath: ctx.directory,
};
if (type === "session.created") {
await wakeOpenClaw("session-start", context, config);
} else if (type === "session.idle") {
await wakeOpenClaw("session-idle", context, config);
} else if (type === "session.deleted") {
await wakeOpenClaw("session-end", context, config);
}
},
"tool.execute.before": async (
input: { tool: string; sessionID: string },
output: { args: Record<string, unknown> }
) => {
const toolName = input.tool.toLowerCase();
const context: OpenClawContext = {
sessionId: input.sessionID,
projectPath: ctx.directory,
};
if (
toolName === "ask_user_question" ||
toolName === "askuserquestion" ||
toolName === "question"
) {
const question =
typeof output.args.question === "string"
? output.args.question
: undefined;
await wakeOpenClaw(
"ask-user-question",
{
...context,
question,
},
config
);
} else if (toolName === "skill") {
const rawName =
typeof output.args.name === "string" ? output.args.name : undefined;
const command = rawName?.replace(/^\//, "").toLowerCase();
if (command === "stop-continuation") {
await wakeOpenClaw("stop", context, config);
}
}
},
};
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "bun:test";
import { resolveGateway } from "../client";
import { type OpenClawConfig } from "../types";
describe("OpenClaw Client", () => {
describe("resolveGateway", () => {
const config: OpenClawConfig = {
enabled: true,
gateways: {
foo: { type: "command", command: "echo foo" },
bar: { type: "http", url: "https://example.com" },
},
hooks: {
"session-start": {
gateway: "foo",
instruction: "start",
enabled: true,
},
"session-end": { gateway: "bar", instruction: "end", enabled: true },
stop: { gateway: "foo", instruction: "stop", enabled: false },
},
};
it("resolves valid mapping", () => {
const result = resolveGateway(config, "session-start");
expect(result).not.toBeNull();
expect(result?.gatewayName).toBe("foo");
expect(result?.instruction).toBe("start");
});
it("returns null for disabled hook", () => {
const result = resolveGateway(config, "stop");
expect(result).toBeNull();
});
it("returns null for unmapped event", () => {
const result = resolveGateway(config, "ask-user-question");
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "bun:test";
import { OpenClawConfigSchema } from "../../config/schema/openclaw";
describe("OpenClaw Config Schema", () => {
it("validates correct config", () => {
const raw = {
enabled: true,
gateways: {
foo: { type: "command", command: "echo foo" },
bar: { type: "http", url: "https://example.com" },
},
hooks: {
"session-start": {
gateway: "foo",
instruction: "start",
enabled: true,
},
},
};
const parsed = OpenClawConfigSchema.safeParse(raw);
if (!parsed.success) console.log(parsed.error);
expect(parsed.success).toBe(true);
});
it("fails on invalid event", () => {
const raw = {
enabled: true,
gateways: {},
hooks: {
"invalid-event": {
gateway: "foo",
instruction: "start",
enabled: true,
},
},
};
const parsed = OpenClawConfigSchema.safeParse(raw);
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from "bun:test";
import {
interpolateInstruction,
resolveCommandTimeoutMs,
shellEscapeArg,
validateGatewayUrl,
wakeCommandGateway,
} from "../dispatcher";
import { type OpenClawCommandGatewayConfig } from "../types";
describe("OpenClaw Dispatcher", () => {
describe("validateGatewayUrl", () => {
it("accepts valid https URLs", () => {
expect(validateGatewayUrl("https://example.com")).toBe(true);
});
it("rejects http URLs (remote)", () => {
expect(validateGatewayUrl("http://example.com")).toBe(false);
});
it("accepts http URLs for localhost", () => {
expect(validateGatewayUrl("http://localhost:3000")).toBe(true);
expect(validateGatewayUrl("http://127.0.0.1:8080")).toBe(true);
});
});
describe("interpolateInstruction", () => {
it("interpolates variables correctly", () => {
const result = interpolateInstruction("Hello {{name}}!", { name: "World" });
expect(result).toBe("Hello World!");
});
it("handles missing variables", () => {
const result = interpolateInstruction("Hello {{name}}!", {});
expect(result).toBe("Hello !");
});
});
describe("shellEscapeArg", () => {
it("escapes simple string", () => {
expect(shellEscapeArg("foo")).toBe("'foo'");
});
it("escapes string with single quotes", () => {
expect(shellEscapeArg("it's")).toBe("'it'\\''s'");
});
});
describe("resolveCommandTimeoutMs", () => {
it("uses default timeout", () => {
expect(resolveCommandTimeoutMs(undefined, undefined)).toBe(5000);
});
it("uses provided timeout", () => {
expect(resolveCommandTimeoutMs(1000, undefined)).toBe(1000);
});
it("clamps timeout", () => {
expect(resolveCommandTimeoutMs(10, undefined)).toBe(100);
expect(resolveCommandTimeoutMs(1000000, undefined)).toBe(300000);
});
});
describe("wakeCommandGateway", () => {
it("rejects if disabled via env", async () => {
const oldEnv = process.env.OMX_OPENCLAW_COMMAND;
process.env.OMX_OPENCLAW_COMMAND = "0";
const config: OpenClawCommandGatewayConfig = {
type: "command",
command: "echo hi",
};
const result = await wakeCommandGateway("test", config, {});
expect(result.success).toBe(false);
expect(result.error).toContain("disabled");
process.env.OMX_OPENCLAW_COMMAND = oldEnv;
});
});
});

256
src/openclaw/client.ts Normal file
View File

@@ -0,0 +1,256 @@
/**
* OpenClaw Integration - Client
*
* Wakes OpenClaw gateways on hook events. Non-blocking, fire-and-forget.
*
* Usage:
* wakeOpenClaw("session-start", { sessionId, projectPath: directory }, config);
*
* Activation requires OMX_OPENCLAW=1 env var and config in pluginConfig.openclaw.
*/
import {
type OpenClawConfig,
type OpenClawContext,
type OpenClawHookEvent,
type OpenClawResult,
type OpenClawGatewayConfig,
type OpenClawHttpGatewayConfig,
type OpenClawCommandGatewayConfig,
type OpenClawPayload,
} from "./types";
import {
interpolateInstruction,
isCommandGateway,
wakeCommandGateway,
wakeGateway,
} from "./dispatcher";
import { execSync } from "child_process";
import { basename } from "path";
/** Whether debug logging is enabled */
const DEBUG = process.env.OMX_OPENCLAW_DEBUG === "1";
// Helper for tmux session
function getCurrentTmuxSession(): string | undefined {
if (!process.env.TMUX) return undefined;
try {
// tmux display-message -p '#S'
const session = execSync("tmux display-message -p '#S'", {
encoding: "utf-8",
}).trim();
return session || undefined;
} catch {
return undefined;
}
}
// Helper for tmux capture
function captureTmuxPane(paneId: string, lines: number): string | undefined {
try {
// tmux capture-pane -p -t {paneId} -S -{lines}
const output = execSync(
`tmux capture-pane -p -t "${paneId}" -S -${lines}`,
{ encoding: "utf-8" }
);
return output || undefined;
} catch {
return undefined;
}
}
/**
* Build a whitelisted context object from the input context.
* Only known fields are included to prevent accidental data leakage.
*/
function buildWhitelistedContext(context: OpenClawContext): OpenClawContext {
const result: OpenClawContext = {};
if (context.sessionId !== undefined) result.sessionId = context.sessionId;
if (context.projectPath !== undefined)
result.projectPath = context.projectPath;
if (context.tmuxSession !== undefined)
result.tmuxSession = context.tmuxSession;
if (context.prompt !== undefined) result.prompt = context.prompt;
if (context.contextSummary !== undefined)
result.contextSummary = context.contextSummary;
if (context.reason !== undefined) result.reason = context.reason;
if (context.question !== undefined) result.question = context.question;
if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail;
if (context.replyChannel !== undefined)
result.replyChannel = context.replyChannel;
if (context.replyTarget !== undefined)
result.replyTarget = context.replyTarget;
if (context.replyThread !== undefined)
result.replyThread = context.replyThread;
return result;
}
/**
* Resolve gateway config for a specific hook event.
* Returns null if the event is not mapped or disabled.
* Returns the gateway name alongside config to avoid O(n) reverse lookup.
*/
export function resolveGateway(
config: OpenClawConfig,
event: OpenClawHookEvent
): {
gatewayName: string;
gateway: OpenClawGatewayConfig;
instruction: string;
} | null {
const mapping = config.hooks?.[event];
if (!mapping || !mapping.enabled) {
return null;
}
const gateway = config.gateways?.[mapping.gateway];
if (!gateway) {
return null;
}
// Validate based on gateway type
if (gateway.type === "command") {
if (!gateway.command) return null;
} else {
// HTTP gateway (default when type is absent or "http")
if (!("url" in gateway) || !gateway.url) return null;
}
return {
gatewayName: mapping.gateway,
gateway,
instruction: mapping.instruction,
};
}
/**
* Wake the OpenClaw gateway mapped to a hook event.
*
* This is the main entry point called from the notify hook.
* Non-blocking, swallows all errors. Returns null if OpenClaw
* is not configured or the event is not mapped.
*
* @param event - The hook event type
* @param context - Context data for template variable interpolation
* @param config - OpenClaw configuration
* @returns OpenClawResult or null if not configured/mapped
*/
export async function wakeOpenClaw(
event: OpenClawHookEvent,
context: OpenClawContext,
config?: OpenClawConfig
): Promise<OpenClawResult | null> {
try {
// Activation gate: only active when OMX_OPENCLAW=1
if (process.env.OMX_OPENCLAW !== "1") {
return null;
}
if (!config || !config.enabled) return null;
const resolved = resolveGateway(config, event);
if (!resolved) return null;
const { gatewayName, gateway, instruction } = resolved;
const now = new Date().toISOString();
// Read originating channel context from env vars
const replyChannel =
context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL ?? undefined;
const replyTarget =
context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET ?? undefined;
const replyThread =
context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD ?? undefined;
// Merge reply context
const enrichedContext: OpenClawContext = {
...context,
...(replyChannel !== undefined && { replyChannel }),
...(replyTarget !== undefined && { replyTarget }),
...(replyThread !== undefined && { replyThread }),
};
// Auto-detect tmux session
const tmuxSession =
enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined;
// Auto-capture tmux pane content
let tmuxTail = enrichedContext.tmuxTail;
if (
!tmuxTail &&
(event === "stop" || event === "session-end") &&
process.env.TMUX
) {
const paneId = process.env.TMUX_PANE;
if (paneId) {
tmuxTail = captureTmuxPane(paneId, 15) ?? undefined;
}
}
// Build template variables
const variables: Record<string, string | undefined> = {
sessionId: enrichedContext.sessionId,
projectPath: enrichedContext.projectPath,
projectName: enrichedContext.projectPath
? basename(enrichedContext.projectPath)
: undefined,
tmuxSession,
prompt: enrichedContext.prompt,
contextSummary: enrichedContext.contextSummary,
reason: enrichedContext.reason,
question: enrichedContext.question,
tmuxTail,
event,
timestamp: now,
replyChannel,
replyTarget,
replyThread,
};
// Interpolate instruction
const interpolatedInstruction = interpolateInstruction(
instruction,
variables
);
variables.instruction = interpolatedInstruction;
let result: OpenClawResult;
if (isCommandGateway(gateway)) {
result = await wakeCommandGateway(gatewayName, gateway, variables);
} else {
const payload: OpenClawPayload = {
event,
instruction: interpolatedInstruction,
text: interpolatedInstruction,
timestamp: now,
sessionId: enrichedContext.sessionId,
projectPath: enrichedContext.projectPath,
projectName: enrichedContext.projectPath
? basename(enrichedContext.projectPath)
: undefined,
tmuxSession,
tmuxTail,
...(replyChannel !== undefined && { channel: replyChannel }),
...(replyTarget !== undefined && { to: replyTarget }),
...(replyThread !== undefined && { threadId: replyThread }),
context: buildWhitelistedContext(enrichedContext),
};
result = await wakeGateway(gatewayName, gateway, payload);
}
if (DEBUG) {
console.error(
`[openclaw] wake ${event} -> ${gatewayName}: ${
result.success ? "ok" : result.error
}`
);
}
return result;
} catch (error) {
if (DEBUG) {
console.error(
`[openclaw] wakeOpenClaw error:`,
error instanceof Error ? error.message : error
);
}
return null;
}
}

317
src/openclaw/dispatcher.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* OpenClaw Gateway Dispatcher
*
* Sends instruction payloads to OpenClaw gateways via HTTP or CLI command.
* All calls are non-blocking with timeouts. Failures are swallowed
* to avoid blocking hooks.
*
* SECURITY: Command gateway requires OMX_OPENCLAW_COMMAND=1 opt-in.
* Command timeout is configurable with safe bounds.
* Prefers execFile for simple commands; falls back to sh -c only for shell metacharacters.
*/
import {
type OpenClawCommandGatewayConfig,
type OpenClawGatewayConfig,
type OpenClawHttpGatewayConfig,
type OpenClawPayload,
type OpenClawResult,
} from "./types";
import { exec, execFile } from "child_process";
/** Default per-request timeout for HTTP gateways */
const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
/** Default command gateway timeout (backward-compatible default) */
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
/**
* Command timeout safety bounds.
* - Minimum 100ms: avoids immediate/near-zero timeout misconfiguration.
* - Maximum 300000ms (5 minutes): prevents runaway long-lived command processes.
*/
const MIN_COMMAND_TIMEOUT_MS = 100;
const MAX_COMMAND_TIMEOUT_MS = 300_000;
/** Shell metacharacters that require sh -c instead of execFile */
const SHELL_METACHAR_RE = /[|&;><`$()]/;
/**
* Validate gateway URL. Must be HTTPS, except localhost/127.0.0.1/::1
* which allows HTTP for local development.
*/
export function validateGatewayUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol === "https:") return true;
if (
parsed.protocol === "http:" &&
(parsed.hostname === "localhost" ||
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "::1" ||
parsed.hostname === "[::1]")
) {
return true;
}
return false;
} catch (err) {
process.stderr.write(`[openclaw-dispatcher] operation failed: ${err}\n`);
return false;
}
}
/**
* Interpolate template variables in an instruction string.
*
* Supported variables (from hook context):
* - {{projectName}} - basename of project directory
* - {{projectPath}} - full project directory path
* - {{sessionId}} - session identifier
* - {{prompt}} - prompt text
* - {{contextSummary}} - context summary (session-end event)
* - {{question}} - question text (ask-user-question event)
* - {{timestamp}} - ISO timestamp
* - {{event}} - hook event name
* - {{instruction}} - interpolated instruction (for command gateway)
* - {{replyChannel}} - originating channel (from OPENCLAW_REPLY_CHANNEL env var)
* - {{replyTarget}} - reply target user/bot (from OPENCLAW_REPLY_TARGET env var)
* - {{replyThread}} - reply thread ID (from OPENCLAW_REPLY_THREAD env var)
*
* Unresolved variables are replaced with empty string.
*/
export function interpolateInstruction(
template: string,
variables: Record<string, string | undefined>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
return variables[key] ?? "";
});
}
/**
* Type guard: is this gateway config a command gateway?
*/
export function isCommandGateway(
config: OpenClawGatewayConfig
): config is OpenClawCommandGatewayConfig {
return config.type === "command";
}
/**
* Shell-escape a string for safe embedding in a shell command.
* Uses single-quote wrapping with internal quote escaping.
*/
export function shellEscapeArg(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/**
* Resolve command gateway timeout with precedence:
* gateway timeout > OMX_OPENCLAW_COMMAND_TIMEOUT_MS > default.
*/
export function resolveCommandTimeoutMs(
gatewayTimeout?: number,
envTimeoutRaw = process.env.OMX_OPENCLAW_COMMAND_TIMEOUT_MS
): number {
const parseFinite = (value: unknown): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return value;
};
const parseEnv = (value: string | undefined): number | undefined => {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const rawTimeout =
parseFinite(gatewayTimeout) ??
parseEnv(envTimeoutRaw) ??
DEFAULT_COMMAND_TIMEOUT_MS;
return Math.min(
MAX_COMMAND_TIMEOUT_MS,
Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout))
);
}
/**
* Wake an HTTP-type OpenClaw gateway with the given payload.
*/
export async function wakeGateway(
gatewayName: string,
gatewayConfig: OpenClawHttpGatewayConfig,
payload: OpenClawPayload
): Promise<OpenClawResult> {
if (!validateGatewayUrl(gatewayConfig.url)) {
return {
gateway: gatewayName,
success: false,
error: "Invalid URL (HTTPS required)",
};
}
try {
const headers = {
"Content-Type": "application/json",
...gatewayConfig.headers,
};
const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(gatewayConfig.url, {
method: gatewayConfig.method || "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
gateway: gatewayName,
success: false,
error: `HTTP ${response.status}`,
statusCode: response.status,
};
}
return { gateway: gatewayName, success: true, statusCode: response.status };
} catch (error) {
return {
gateway: gatewayName,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Wake a command-type OpenClaw gateway by executing a shell command.
*
* SECURITY REQUIREMENTS:
* - Requires OMX_OPENCLAW_COMMAND=1 opt-in (separate gate from OMX_OPENCLAW)
* - Timeout is configurable via gateway.timeout or OMX_OPENCLAW_COMMAND_TIMEOUT_MS
* with safe clamping bounds and backward-compatible default 5000ms
* - Prefers execFile for simple commands (no metacharacters)
* - Falls back to sh -c only when metacharacters detected
* - detached: false to prevent orphan processes
* - SIGTERM cleanup handler kills child on parent SIGTERM, 1s grace then SIGKILL
*
* The command template supports {{variable}} placeholders. All variable
* values are shell-escaped before interpolation to prevent injection.
*/
export async function wakeCommandGateway(
gatewayName: string,
gatewayConfig: OpenClawCommandGatewayConfig,
variables: Record<string, string | undefined>
): Promise<OpenClawResult> {
// Separate command gateway opt-in gate
if (process.env.OMX_OPENCLAW_COMMAND !== "1") {
return {
gateway: gatewayName,
success: false,
error: "Command gateway disabled (set OMX_OPENCLAW_COMMAND=1 to enable)",
};
}
let child: any = null;
let sigtermHandler: (() => void) | null = null;
try {
const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout);
// Interpolate variables with shell escaping
const interpolated = gatewayConfig.command.replace(
/\{\{(\w+)\}\}/g,
(match, key) => {
const value = variables[key];
if (value === undefined) return match;
return shellEscapeArg(value);
}
);
// Detect whether the interpolated command contains shell metacharacters
const hasMetachars = SHELL_METACHAR_RE.test(interpolated);
await new Promise<void>((resolve, reject) => {
const cleanup = (signal: NodeJS.Signals) => {
if (child) {
child.kill(signal);
// 1s grace period then SIGKILL
setTimeout(() => {
try {
child?.kill("SIGKILL");
} catch (err) {
process.stderr.write(
`[openclaw-dispatcher] operation failed: ${err}\n`
);
}
}, 1000);
}
};
sigtermHandler = () => cleanup("SIGTERM");
process.once("SIGTERM", sigtermHandler);
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
if (sigtermHandler) {
process.removeListener("SIGTERM", sigtermHandler);
sigtermHandler = null;
}
if (signal) {
reject(new Error(`Command killed by signal ${signal}`));
} else if (code !== 0) {
reject(new Error(`Command exited with code ${code}`));
} else {
resolve();
}
};
const onError = (err: Error) => {
if (sigtermHandler) {
process.removeListener("SIGTERM", sigtermHandler);
sigtermHandler = null;
}
reject(err);
};
if (hasMetachars) {
// Fall back to sh -c for complex commands with metacharacters
child = exec(interpolated, {
timeout,
env: { ...process.env },
});
} else {
// Parse simple command: split on whitespace, use execFile
const parts = interpolated.split(/\s+/).filter(Boolean);
const cmd = parts[0];
const args = parts.slice(1);
child = execFile(cmd, args, {
timeout,
env: { ...process.env },
});
}
// Ensure detached is false (default, but explicit via options above)
if (child) {
child.on("exit", onExit);
child.on("error", onError);
} else {
reject(new Error("Failed to spawn process"));
}
});
return { gateway: gatewayName, success: true };
} catch (error) {
// Ensure SIGTERM handler is cleaned up on error
if (sigtermHandler) {
process.removeListener("SIGTERM", sigtermHandler as () => void);
}
return {
gateway: gatewayName,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

10
src/openclaw/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export { resolveGateway, wakeOpenClaw } from "./client";
export {
interpolateInstruction,
isCommandGateway,
shellEscapeArg,
validateGatewayUrl,
wakeCommandGateway,
wakeGateway,
} from "./dispatcher";
export * from "./types";

134
src/openclaw/types.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* OpenClaw Gateway Integration Types
*
* Defines types for the OpenClaw gateway waker system.
* Each hook event can be mapped to a gateway with a pre-defined instruction.
*/
/** Hook events that can trigger OpenClaw gateway calls */
export type OpenClawHookEvent =
| "session-start"
| "session-end"
| "session-idle"
| "ask-user-question"
| "stop";
/** HTTP gateway configuration (default when type is absent or "http") */
export interface OpenClawHttpGatewayConfig {
/** Gateway type discriminator (optional for backward compat) */
type?: "http";
/** Gateway endpoint URL (HTTPS required, HTTP allowed for localhost) */
url: string;
/** Optional custom headers (e.g., Authorization) */
headers?: Record<string, string>;
/** HTTP method (default: POST) */
method?: "POST" | "PUT";
/** Per-request timeout in ms (default: 10000) */
timeout?: number;
}
/** CLI command gateway configuration */
export interface OpenClawCommandGatewayConfig {
/** Gateway type discriminator */
type: "command";
/** Command template with {{variable}} placeholders.
* Variables are shell-escaped automatically before interpolation. */
command: string;
/**
* Per-command timeout in ms.
* Precedence: gateway timeout > OMX_OPENCLAW_COMMAND_TIMEOUT_MS > default (5000ms).
* Runtime clamps to safe bounds.
*/
timeout?: number;
}
/** Gateway configuration — HTTP or CLI command */
export type OpenClawGatewayConfig =
| OpenClawHttpGatewayConfig
| OpenClawCommandGatewayConfig;
/** Per-hook-event mapping to a gateway + instruction */
export interface OpenClawHookMapping {
/** Name of the gateway (key in gateways object) */
gateway: string;
/** Instruction template with {{variable}} placeholders */
instruction: string;
/** Whether this hook-event mapping is active */
enabled: boolean;
}
/** Top-level config schema for notifications.openclaw key in .omx-config.json */
export interface OpenClawConfig {
/** Global enable/disable */
enabled: boolean;
/** Named gateway endpoints */
gateways: Record<string, OpenClawGatewayConfig>;
/** Hook-event to gateway+instruction mappings */
hooks?: Partial<Record<OpenClawHookEvent, OpenClawHookMapping>>;
}
/** Payload sent to an OpenClaw gateway */
export interface OpenClawPayload {
/** The hook event that triggered this call */
event: OpenClawHookEvent;
/** Interpolated instruction text */
instruction: string;
/** Alias of instruction — allows OpenClaw /hooks/wake to consume the payload directly */
text: string;
/** ISO timestamp */
timestamp: string;
/** Session identifier (if available) */
sessionId?: string;
/** Project directory path */
projectPath?: string;
/** Project basename */
projectName?: string;
/** Tmux session name (if running inside tmux) */
tmuxSession?: string;
/** Recent tmux pane output (for stop/session-end events) */
tmuxTail?: string;
/** Originating channel for reply routing (if OPENCLAW_REPLY_CHANNEL is set) */
channel?: string;
/** Reply target user/bot (if OPENCLAW_REPLY_TARGET is set) */
to?: string;
/** Reply thread ID (if OPENCLAW_REPLY_THREAD is set) */
threadId?: string;
/** Context data from the hook (whitelisted fields only) */
context: OpenClawContext;
}
/**
* Context data passed from the hook to OpenClaw for template interpolation.
*
* All fields are explicitly enumerated (no index signature) to prevent
* accidental leakage of sensitive data into gateway payloads.
*/
export interface OpenClawContext {
sessionId?: string;
projectPath?: string;
tmuxSession?: string;
prompt?: string;
contextSummary?: string;
reason?: string;
question?: string;
/** Recent tmux pane output (captured automatically for stop/session-end events) */
tmuxTail?: string;
/** Originating channel for reply routing (from OPENCLAW_REPLY_CHANNEL env var) */
replyChannel?: string;
/** Reply target user/bot (from OPENCLAW_REPLY_TARGET env var) */
replyTarget?: string;
/** Reply thread ID for threaded conversations (from OPENCLAW_REPLY_THREAD env var) */
replyThread?: string;
}
/** Result of a gateway wake attempt */
export interface OpenClawResult {
/** Gateway name */
gateway: string;
/** Whether the call succeeded */
success: boolean;
/** Error message if failed */
error?: string;
/** HTTP status code if available */
statusCode?: number;
}

View File

@@ -215,6 +215,7 @@ export function createEventHandler(args: {
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
await Promise.resolve(hooks.openclawSender?.event?.(input));
await Promise.resolve(hooks.autoSlashCommand?.event?.(input));
};

View File

@@ -26,6 +26,7 @@ import {
createPreemptiveCompactionHook,
createRuntimeFallbackHook,
} from "../../hooks"
import { createOpenClawSenderHook } from "../../hooks/openclaw-sender"
import { createAnthropicEffortHook } from "../../hooks/anthropic-effort"
import {
detectExternalNotificationPlugin,
@@ -60,6 +61,7 @@ export type SessionHooks = {
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook> | null
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
openclawSender: ReturnType<typeof createOpenClawSenderHook> | null
}
export function createSessionHooks(args: {
@@ -261,6 +263,11 @@ export function createSessionHooks(args: {
pluginConfig,
}))
: null
const openclawSender = isHookEnabled("openclaw-sender") && pluginConfig.openclaw?.enabled
? safeHook("openclaw-sender", () => createOpenClawSenderHook(ctx, pluginConfig.openclaw!))
: null
return {
contextWindowMonitor,
preemptiveCompaction,
@@ -285,5 +292,6 @@ export function createSessionHooks(args: {
taskResumeInfo,
anthropicEffort,
runtimeFallback,
openclawSender,
}
}

View File

@@ -33,6 +33,7 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
await hooks.openclawSender?.["tool.execute.before"]?.(input, output)
const normalizedToolName = input.tool.toLowerCase()
if (