refactor: use OpenClaw native task state

This commit is contained in:
Haitao Pan 2026-06-05 21:25:29 +08:00
parent c462ed6cce
commit 34be232931
13 changed files with 1160 additions and 915 deletions

View File

@ -4,16 +4,17 @@ OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifac
## Why ## Why
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing
`/gateway/openclaw` task contract. The bridge sends `chat.send`, waits for `/acp` and `/acp/rpc` contract with OpenClaw routing metadata. The bridge sends
`agent.wait`, then asks this plugin for a session/run-scoped artifact manifest. `chat.send`, waits for `agent.wait`, then asks this plugin for a session/run-scoped artifact manifest.
The APP can then sync generated files into its local thread workspace without The APP can then sync generated files into its local thread workspace without
changing the UI or adding provider-specific routes. changing the UI or adding provider-specific routes.
This plugin is not a scheduler. OpenClaw core owns sub-agents, multi-agent This plugin is not a scheduler or bridge client. OpenClaw core owns sub-agents,
routing, queues, cron, and cross-session execution. This package only adapts multi-agent routing, queues, cron, task registry state, and cross-session
those existing OpenClaw multi-task/session identities into isolated artifact execution. This package only adapts those existing OpenClaw task/session
directories and signed artifact reads. identities into isolated artifact directories, session key mapping, and signed
artifact reads.
It registers four Gateway methods: It registers four Gateway methods:
@ -141,12 +142,15 @@ Export response payload:
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`. Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
When `artifactScope` is omitted, export/list defaults to the current task scope When `artifactScope` is omitted, export/list defaults to the current task scope
derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task
scope. The plugin does not adopt files from the workspace root; agents must scope. The prepared task scope remains authoritative: when it contains files,
write final deliverables directly into the prepared `artifactDirectory`. the plugin exports only that scope.
The plugin never scans workspace root, `owners/*/threads/*`, or any previous If the prepared task scope is empty, trusted Gateway callers may pass
thread workspace as a fallback and does not borrow artifacts from earlier task `expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then
scopes. scans only those explicit workspace-root subdirectories and labels the exported
files with the current task `artifactScope`. It never performs a broad workspace
root scan, never scans `owners/*/threads/*`, and does not borrow artifacts from
earlier task scopes.
Each exported artifact includes `artifactRef`, a plugin-signed reference over Each exported artifact includes `artifactRef`, a plugin-signed reference over
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
@ -186,17 +190,12 @@ Gateway clients can use:
pipeline, not in `chat.send` params. If `chat.send` returns a different pipeline, not in `chat.send` params. If `chat.send` returns a different
OpenClaw `runId`, prepare/export with that actual `runId` instead of the OpenClaw `runId`, prepare/export with that actual `runId` instead of the
bridge request id. bridge request id.
- `openclaw_multi_session_agents` from an OpenClaw task to call XWorkmate Bridge
`/acp/rpc` with `multiAgent=true`, while deriving `sessionKey`, `runId`, and
`workspaceDir` from the host task context instead of model-controlled tool
parameters.
- `xworkmate.agents.run` for trusted gateway callers that need the same
bridge-backed multi-agent run and artifact-scope export in one method.
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. - `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file. - `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file. - `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file.
- `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope. - `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope.
- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. - `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. Pass `expectedArtifactDirs` when the task contract declares root-level delivery directories.
- `xworkmate.tasks.get` to read the OpenClaw native task state for a run and return the current artifact export in the same payload.
Large files are metadata-only in the export payload, but XWorkmate Bridge can Large files are metadata-only in the export payload, but XWorkmate Bridge can
generate its own signed download URL and call `xworkmate.artifacts.read` as the generate its own signed download URL and call `xworkmate.artifacts.read` as the
@ -207,7 +206,7 @@ only remote file access path.
- Only files inside the resolved OpenClaw workspace are exported. - Only files inside the resolved OpenClaw workspace are exported.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, transient framework state, and dependency folders are excluded from task artifact exports. - `.git`, `.openclaw`, `.xworkmate`, `.pi`, transient framework state, and dependency folders are excluded from task artifact exports.
- `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively. - `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively.
- Export never adopts files from the workspace root or OpenClaw owner/thread workspaces; agents must write into the prepared task scope. - Export scans workspace-root files only from explicit `expectedArtifactDirs`, only when the prepared task scope is empty, and never from OpenClaw owner/thread workspaces.
- Symlinks are skipped to avoid workspace escape. - Symlinks are skipped to avoid workspace escape.
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined. - Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`. - `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.

147
dist/index.js vendored
View File

@ -1,6 +1,6 @@
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js"; import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; import { createOrUpdateXWorkmateTaskRecord, createXWorkmateTaskStore, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateDetachedTaskRuntime, registerXWorkmateSessionExtension, } from "./src/taskState.js";
function scopedGatewayParams(params) { function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope; const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
const runScope = resolveRunScope({ sessionScope }); const runScope = resolveRunScope({ sessionScope });
@ -37,19 +37,49 @@ const plugin = {
}; };
export default plugin; export default plugin;
function register(api) { function register(api) {
const taskStore = createXWorkmateTaskStore();
registerXWorkmateSessionExtension(api);
registerXWorkmateDetachedTaskRuntime(api, taskStore);
api.registerHook("session.start", async (event) => { api.registerHook("session.start", async (event) => {
try { try {
const params = scopedGatewayParams(event?.context ?? event); const params = scopedGatewayParams(event?.context ?? event);
if (params.sessionKey && params.runId) { if (params.sessionKey && params.runId) {
await prepareXWorkmateArtifacts({ createOrUpdateXWorkmateTaskRecord(taskStore, {
params,
status: "running",
progressSummary: "OpenClaw task is running",
});
const prepared = await prepareXWorkmateArtifacts({
params, params,
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
await recordXWorkmateSessionMapping({
api,
taskStore,
params,
artifactScope: prepared.artifactScope,
});
} }
} }
catch (e) { catch (error) {
// Ignored: best-effort preparation api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`);
}
}, { name: "openclaw-multi-session-plugins.session-start" });
api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => {
try {
const payload = await getXWorkmateTaskSnapshot({
api,
taskStore,
params: scopedGatewayParams(opts.params),
});
opts.respond(true, payload, undefined);
}
catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
} }
}); });
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => { api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
@ -132,30 +162,10 @@ function register(api) {
}); });
} }
}); });
api.registerGatewayMethod("xworkmate.agents.run", async (opts) => {
try {
const payload = await runXWorkmateBridgeAgents({
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
opts.respond(true, payload, undefined);
}
catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
}
});
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
names: ["openclaw_multi_session_artifacts"], names: ["openclaw_multi_session_artifacts"],
optional: true, optional: true,
}); });
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
names: ["openclaw_multi_session_agents"],
optional: true,
});
} }
function createXWorkmateArtifactsTool(api, ctx) { function createXWorkmateArtifactsTool(api, ctx) {
return { return {
@ -248,92 +258,3 @@ function createXWorkmateArtifactsTool(api, ctx) {
}, },
}; };
} }
function createXWorkmateAgentsTool(api, ctx) {
return {
name: "openclaw_multi_session_agents",
label: "XWorkmate multi-agent bridge",
description: "Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
taskPrompt: {
type: "string",
description: "Overall multi-agent task prompt.",
},
mode: {
type: "string",
enum: ["sequence", "parallel", "race", "conversation"],
description: "Multi-agent orchestration mode.",
},
steps: {
type: "array",
description: "Agent steps. Each item needs providerId and prompt.",
items: {
type: "object",
additionalProperties: false,
properties: {
providerId: { type: "string" },
prompt: { type: "string" },
outputAs: { type: "string" },
timeoutMs: { type: "number" },
},
required: ["providerId", "prompt"],
},
},
participants: {
type: "array",
description: "Conversation participants by providerId.",
items: { type: "string" },
},
maxTurns: {
type: "number",
description: "Maximum turns for conversation mode.",
},
stopConditions: {
type: "array",
description: "Text markers that stop conversation mode.",
items: { type: "string" },
},
timeoutMs: {
type: "number",
description: "Overall bridge request timeout.",
},
},
required: ["taskPrompt"],
},
async execute(_id, params) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const payload = await runXWorkmateBridgeAgents({
params: {
...operationParams,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
},
config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig,
});
const summary = typeof payload.bridgeResult.summary === "string"
? payload.bridgeResult.summary
: typeof payload.bridgeResult.output === "string"
? payload.bridgeResult.output
: "Multi-agent run completed.";
return {
content: [{ type: "text", text: [summary, "", formatArtifactManifestMarkdown(payload)].join("\n") }],
details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult },
};
},
};
}

View File

@ -1,11 +0,0 @@
import { type XWorkmateArtifactExport } from "./exportArtifacts.js";
type BridgeAgentInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
type BridgeAgentRun = XWorkmateArtifactExport & {
bridgeResult: Record<string, unknown>;
};
export declare function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun>;
export {};

View File

@ -1,205 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, } from "./exportArtifacts.js";
export async function runXWorkmateBridgeAgents(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const runId = requiredString(params.runId, "runId required");
const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required");
const bridgeUrl = bridgeRpcUrl(pluginConfig);
const bridgeToken = bridgeAuthToken(pluginConfig);
if (!bridgeToken) {
throw new Error("bridgeToken required");
}
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey, runId, workspaceDir: params.workspaceDir },
config: input.config,
pluginConfig,
});
const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence";
const participants = safeStringList(params.participants);
const steps = safeSteps(params.steps, participants.length > 0);
if (steps.length === 0 && participants.length === 0) {
throw new Error("steps or participants required");
}
const routing = {
orchestrationMode,
steps,
};
if (participants.length > 0) {
routing.participants = participants;
}
const maxTurns = positiveInteger(params.maxTurns, 0);
if (maxTurns > 0) {
routing.maxTurns = maxTurns;
}
const stopConditions = safeStringList(params.stopConditions);
if (stopConditions.length > 0) {
routing.stopConditions = stopConditions;
}
const bridgeResult = await callBridgeRPC({
bridgeUrl,
bridgeToken,
timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)),
body: {
jsonrpc: "2.0",
id: `openclaw-${Date.now()}`,
method: "session.start",
params: {
sessionId: `openclaw:${sessionKey}`,
threadId: sessionKey,
taskPrompt,
workingDirectory: prepared.artifactDirectory,
multiAgent: true,
mode: "multi-agent",
routing,
},
},
});
await fs.mkdir(prepared.artifactDirectory, { recursive: true });
await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.json"), `${JSON.stringify(bridgeResult, null, 2)}\n`);
await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.md"), formatBridgeResultMarkdown(bridgeResult));
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey,
runId,
workspaceDir: params.workspaceDir,
artifactScope: prepared.artifactScope,
includeContent: false,
},
config: input.config,
pluginConfig,
});
return { ...exported, bridgeResult };
}
async function callBridgeRPC(input) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
try {
const response = await fetch(input.bridgeUrl, {
method: "POST",
headers: {
Authorization: bearer(input.bridgeToken),
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input.body),
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`bridge request failed (${response.status}): ${text.trim()}`);
}
const decoded = JSON.parse(text);
const error = asRecord(decoded.error);
if (error) {
throw new Error(optionalString(error.message) || "bridge rpc error");
}
const result = asRecord(decoded.result);
if (!result) {
throw new Error("bridge response missing result");
}
return result;
}
finally {
clearTimeout(timer);
}
}
function bridgeRpcUrl(pluginConfig) {
const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL);
if (!configured) {
throw new Error("bridgeUrl required");
}
const trimmed = configured.replace(/\/+$/, "");
if (trimmed.endsWith("/acp/rpc")) {
return trimmed;
}
return `${trimmed}/acp/rpc`;
}
function bridgeAuthToken(pluginConfig) {
return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN);
}
function safeSteps(raw, allowEmpty) {
if (!Array.isArray(raw)) {
if (allowEmpty) {
return [];
}
throw new Error("steps required");
}
return raw.map((item, index) => {
const mapped = asRecord(item);
if (!mapped) {
throw new Error(`steps[${index}] must be an object`);
}
const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent);
const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt);
if (!providerId) {
throw new Error(`steps[${index}].providerId required`);
}
if (!prompt) {
throw new Error(`steps[${index}].prompt required`);
}
return {
providerId,
prompt,
...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}),
...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}),
};
});
}
function safeStringList(raw) {
if (!Array.isArray(raw)) {
return [];
}
return raw.map((value) => optionalString(value)).filter((value) => value.length > 0);
}
function formatBridgeResultMarkdown(result) {
const lines = ["# Multi-Agent Result", ""];
lines.push(`- Status: ${optionalString(result.status) || "unknown"}`);
lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`);
const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message);
if (summary) {
lines.push("", "## Summary", "", summary);
}
const steps = Array.isArray(result.steps) ? result.steps : [];
if (steps.length > 0) {
lines.push("", "## Steps", "");
for (const item of steps) {
const step = asRecord(item) ?? {};
lines.push(`- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${optionalString(step.error) ? ` (${optionalString(step.error)})` : ""}`);
}
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function bearer(token) {
return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
}
function requiredString(value, message) {
const text = optionalString(value);
if (!text) {
throw new Error(message);
}
return text;
}
function optionalString(value) {
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return "";
}
const text = String(value).trim();
return text === "<nil>" ? "" : text;
}
function positiveInteger(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.floor(parsed);
}
function asRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value;
}

57
dist/src/taskState.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
type XWorkmateTaskRecord = {
taskId: string;
runtime: "acp";
taskKind: "xworkmate-openclaw";
requesterSessionKey: string;
ownerKey: string;
scopeKind: "session";
runId: string;
label: string;
task: string;
status: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "lost";
deliveryStatus: "pending" | "delivered" | "session_queued" | "failed" | "parent_missing" | "not_applicable";
notifyPolicy: "done_only" | "state_changes" | "silent";
createdAt: number;
startedAt?: number;
endedAt?: number;
lastEventAt?: number;
error?: string;
progressSummary?: string;
terminalSummary?: string;
terminalOutcome?: "succeeded" | "blocked";
};
type XWorkmateSessionMapping = {
appSessionKey: string;
openClawSessionKey: string;
appThreadId?: string;
sessionId?: string;
runId: string;
artifactScope?: string;
expectedArtifactDirs?: string[];
};
export type XWorkmateTaskStore = {
records: Map<string, XWorkmateTaskRecord>;
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
};
export declare function createXWorkmateTaskStore(): XWorkmateTaskStore;
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
export declare function recordXWorkmateSessionMapping(input: {
api: OpenClawPluginApi;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
artifactScope?: string;
}): Promise<void>;
export declare function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore): void;
export declare function getXWorkmateTaskSnapshot(input: {
api: OpenClawPluginApi;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
}): Promise<Record<string, unknown>>;
export declare function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: {
params: Record<string, unknown>;
status: XWorkmateTaskRecord["status"];
progressSummary?: string;
}): XWorkmateTaskRecord;
export {};

351
dist/src/taskState.js vendored Normal file
View File

@ -0,0 +1,351 @@
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export function createXWorkmateTaskStore() {
return {
records: new Map(),
sessionMappingsByAppKey: new Map(),
sessionMappingsByOpenClawKey: new Map(),
};
}
export function registerXWorkmateSessionExtension(api) {
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
if (typeof registerExtension !== "function") {
return;
}
registerExtension({
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.",
sessionEntrySlotKey: "xworkmate",
project: (ctx) => {
const state = asRecord(ctx.state) ?? {};
const appSessionKey = optionalString(state.appSessionKey) ||
optionalString(state.appThreadId) ||
optionalString(state.threadId) ||
appSessionKeyFromOpenClawSessionKey(ctx.sessionKey);
const openClawSessionKey = optionalString(state.openClawSessionKey) || ctx.sessionKey;
return {
...state,
appSessionKey,
openClawSessionKey,
sessionId: optionalString(state.sessionId) || optionalString(ctx.sessionId),
};
},
});
}
export async function recordXWorkmateSessionMapping(input) {
const appSessionKey = requiredString(input.params.sessionKey || input.params.appSessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const openClawSessionKey = optionalString(input.params.openClawSessionKey) ||
optionalString(input.params.openClawSessionId) ||
agentMainSessionKeyFor(appSessionKey);
const expectedArtifactDirs = stringList(input.params.expectedArtifactDirs);
const mapping = compactObject({
appSessionKey,
openClawSessionKey,
appThreadId: optionalString(input.params.threadId) || appSessionKey,
sessionId: optionalString(input.params.sessionId),
runId,
artifactScope: input.artifactScope || optionalString(input.params.artifactScope),
expectedArtifactDirs: expectedArtifactDirs.length > 0 ? expectedArtifactDirs : undefined,
});
input.taskStore.sessionMappingsByAppKey.set(appSessionKey, mapping);
input.taskStore.sessionMappingsByOpenClawKey.set(openClawSessionKey, mapping);
const patchSessionExtension = resolvePatchSessionExtension(input.api);
if (!patchSessionExtension) {
// Legacy fallback owner: this plugin. Scope: tests and OpenClaw hosts that do not expose
// session extension patching yet. Exit: remove this map once 2026.6.1+ hosts expose the patch
// method on the public plugin API in all supported deployments.
return;
}
await patchSessionExtension({
key: openClawSessionKey,
sessionKey: openClawSessionKey,
pluginId: XWORKMATE_PLUGIN_ID,
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
value: mapping,
});
}
export function registerXWorkmateDetachedTaskRuntime(api, taskStore) {
const registerRuntime = api.registerDetachedTaskRuntime;
if (typeof registerRuntime !== "function") {
return;
}
registerRuntime({
createQueuedTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }),
createRunningTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }),
startTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }),
recordTaskRunProgressByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
lastEventAt: Date.now(),
progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary),
}),
finalizeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)),
completeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: "succeeded",
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
terminalOutcome: "succeeded",
}),
failTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: taskStatusFrom(params.status, "failed"),
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
}),
setDetachedTaskDeliveryStatusByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"),
error: optionalString(params.error),
}),
cancelDetachedTaskRunById: async (params) => {
const taskId = optionalString(params.taskId);
const record = taskId ? findXWorkmateTaskByTaskId(taskStore, taskId) : undefined;
if (!record) {
return { found: false, cancelled: false };
}
record.status = "cancelled";
record.endedAt = Date.now();
record.lastEventAt = record.endedAt;
return { found: true, cancelled: true, reason: optionalString(params.reason), task: record };
},
});
}
export async function getXWorkmateTaskSnapshot(input) {
const sessionKey = requiredString(input.params.sessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const mapping = resolveSessionMapping(input.taskStore, input.params, sessionKey);
const openClawSessionKey = mapping?.openClawSessionKey || optionalString(input.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey);
const appSessionKey = mapping?.appSessionKey || sessionKey;
const nativeTask = resolveNativeTask(input.api, openClawSessionKey, runId) || resolveNativeTask(input.api, sessionKey, runId);
const storedTask = findXWorkmateTask(input.taskStore, sessionKey, runId);
const exported = await exportXWorkmateArtifacts({
params: input.params,
config: input.api.config,
pluginConfig: input.api.pluginConfig,
});
const task = nativeTask || storedTask;
const taskStatus = normalizeTaskStatus(optionalString(task.status), exported.artifacts.length > 0);
if (storedTask && taskStatus === "succeeded" && storedTask.status !== "succeeded") {
storedTask.status = "succeeded";
storedTask.endedAt = Date.now();
storedTask.lastEventAt = storedTask.endedAt;
storedTask.terminalOutcome = "succeeded";
}
return {
success: true,
status: appStatusFromTaskStatus(taskStatus),
taskStatus,
mode: "gateway-chat",
sessionKey,
openClawSessionKey,
appSessionKey,
runId,
task,
artifactScope: exported.artifactScope,
remoteWorkingDirectory: exported.remoteWorkingDirectory,
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
scopeKind: exported.scopeKind,
artifacts: exported.artifacts,
warnings: exported.warnings,
artifactCount: exported.artifacts.length,
};
}
export function createOrUpdateXWorkmateTaskRecord(input, options) {
const sessionKey = requiredString(options.params.sessionKey || options.params.requesterSessionKey, "sessionKey required");
const runId = requiredString(options.params.runId, "runId required");
const key = taskRecordKey(sessionKey, runId);
const now = Date.now();
const existing = input.records.get(key);
if (existing) {
existing.status = options.status;
existing.lastEventAt = now;
if (options.status === "running" && !existing.startedAt) {
existing.startedAt = now;
}
if (options.progressSummary) {
existing.progressSummary = options.progressSummary;
}
return existing;
}
const record = {
taskId: `xworkmate:${safeTaskIdSegment(sessionKey)}:${safeTaskIdSegment(runId)}`,
runtime: "acp",
taskKind: "xworkmate-openclaw",
requesterSessionKey: optionalString(options.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey),
ownerKey: sessionKey,
scopeKind: "session",
runId,
label: optionalString(options.params.label) || "XWorkmate OpenClaw task",
task: optionalString(options.params.taskPrompt) || optionalString(options.params.task) || "XWorkmate OpenClaw task",
status: options.status,
deliveryStatus: "pending",
notifyPolicy: "state_changes",
createdAt: now,
startedAt: options.status === "running" ? now : undefined,
lastEventAt: now,
progressSummary: options.progressSummary,
};
input.records.set(key, record);
return record;
}
function updateXWorkmateTaskRecordsByRunId(input, params, patch) {
const runId = optionalString(params.runId);
const sessionKey = optionalString(params.sessionKey || params.requesterSessionKey);
const records = [...input.records.values()].filter((record) => {
if (runId && record.runId !== runId) {
return false;
}
if (sessionKey && record.ownerKey !== sessionKey && record.requesterSessionKey !== sessionKey) {
return false;
}
return true;
});
for (const record of records) {
Object.assign(record, compactObject(patch));
}
return records;
}
function resolveNativeTask(api, sessionKey, runId) {
try {
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey });
const resolved = bound?.resolve?.(runId) || bound?.get?.(runId);
return asRecord(resolved);
}
catch (error) {
api.logger?.warn?.(`xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`);
return undefined;
}
}
function resolveSessionMapping(input, params, sessionKey) {
const explicitOpenClawKey = optionalString(params.openClawSessionKey);
if (explicitOpenClawKey) {
const byOpenClaw = input.sessionMappingsByOpenClawKey.get(explicitOpenClawKey);
if (byOpenClaw) {
return byOpenClaw;
}
}
return input.sessionMappingsByAppKey.get(sessionKey) || input.sessionMappingsByOpenClawKey.get(sessionKey);
}
function findXWorkmateTask(input, sessionKey, runId) {
return input.records.get(taskRecordKey(sessionKey, runId));
}
function findXWorkmateTaskByTaskId(input, taskId) {
return [...input.records.values()].find((record) => record.taskId === taskId);
}
function taskRecordKey(sessionKey, runId) {
return `${sessionKey}\u0000${runId}`;
}
function appSessionKeyFromOpenClawSessionKey(sessionKey) {
return sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
}
function agentMainSessionKeyFor(sessionKey) {
return sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
}
function terminalPatch(params) {
const status = taskStatusFrom(params.status, "succeeded");
return {
status,
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
progressSummary: optionalString(params.progressSummary),
terminalSummary: optionalString(params.terminalSummary),
terminalOutcome: status === "succeeded" ? "succeeded" : "blocked",
};
}
function normalizeTaskStatus(status, hasArtifacts) {
const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running");
if (normalized === "running" && hasArtifacts) {
return "succeeded";
}
return normalized;
}
function appStatusFromTaskStatus(status) {
if (status === "succeeded") {
return "completed";
}
if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") {
return "failed";
}
return "running";
}
function taskStatusFrom(value, fallback) {
const status = optionalString(value);
if (status === "queued" ||
status === "running" ||
status === "succeeded" ||
status === "failed" ||
status === "timed_out" ||
status === "cancelled" ||
status === "lost") {
return status;
}
return fallback;
}
function deliveryStatusFrom(value, fallback) {
const status = optionalString(value);
if (status === "pending" ||
status === "delivered" ||
status === "session_queued" ||
status === "failed" ||
status === "parent_missing" ||
status === "not_applicable") {
return status;
}
return fallback;
}
function resolvePatchSessionExtension(api) {
const stateApi = (api.session?.state ?? {});
const apiRecord = api;
const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension;
return typeof candidate === "function"
? candidate
: undefined;
}
function requiredString(value, message) {
const text = optionalString(value);
if (!text) {
throw new Error(message);
}
return text;
}
function optionalString(value) {
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return "";
}
const text = String(value).trim();
return text === "<nil>" ? "" : text;
}
function stringList(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const result = [];
for (const entry of value) {
const text = optionalString(entry);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function numberOrNow(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
}
function asRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value;
}
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== ""));
}
function safeTaskIdSegment(value) {
return value.replace(/[^A-Za-z0-9._:-]+/g, "_");
}

View File

@ -1,5 +1,4 @@
import fs from "node:fs"; import fs from "node:fs";
import http from "node:http";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
@ -22,12 +21,12 @@ describe("plugin registration", () => {
}; };
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts"); expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_agents"); expect(manifest.contracts?.tools).not.toContain("openclaw_multi_session_agents");
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts"); expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_agents"); expect(manifest.contracts?.sessionScopedTools).not.toContain("openclaw_multi_session_agents");
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy(); expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
expect(manifest.configSchema?.properties?.bridgeUrl).toBeTruthy(); expect(manifest.configSchema?.properties?.bridgeUrl).toBeUndefined();
expect(manifest.configSchema?.properties?.bridgeToken).toBeTruthy(); expect(manifest.configSchema?.properties?.bridgeToken).toBeUndefined();
}); });
it("registers the xworkmate gateway methods and optional tools", () => { it("registers the xworkmate gateway methods and optional tools", () => {
@ -43,29 +42,24 @@ describe("plugin registration", () => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
registerHook: () => undefined, registerHook: () => undefined,
} as unknown as OpenClawPluginApi; } as unknown as OpenClawPluginApi;
plugin.register(api); plugin.register(api);
expect(methods.map((entry) => entry.method)).toEqual([ expect(methods.map((entry) => entry.method)).toEqual([
"xworkmate.tasks.get",
"xworkmate.artifacts.prepare", "xworkmate.artifacts.prepare",
"xworkmate.artifacts.export", "xworkmate.artifacts.export",
"xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.collect-and-snapshot",
"xworkmate.artifacts.list", "xworkmate.artifacts.list",
"xworkmate.artifacts.read", "xworkmate.artifacts.read",
"xworkmate.agents.run",
]); ]);
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true); expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
expect(tools).toHaveLength(2); expect(tools).toHaveLength(1);
expect(tools[0]?.options).toMatchObject({ expect(tools[0]?.options).toMatchObject({
names: ["openclaw_multi_session_artifacts"], names: ["openclaw_multi_session_artifacts"],
optional: true, optional: true,
}); });
expect(tools[1]?.options).toMatchObject({
names: ["openclaw_multi_session_agents"],
optional: true,
});
}); });
it("executes registered gateway methods against the current task scope", async () => { it("executes registered gateway methods against the current task scope", async () => {
@ -131,6 +125,124 @@ describe("plugin registration", () => {
expect(unprepared.payload?.warnings).toEqual(["artifact scope is not prepared for this task run"]); expect(unprepared.payload?.warnings).toEqual(["artifact scope is not prepared for this task run"]);
}); });
it("registers xworkmate task state against the native session extension and task runtime seams", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-task-state-"));
const methods = new Map<string, GatewayMethodHandler>();
const hooks = new Map<string, (event: unknown) => Promise<void>>();
const sessionExtensions: Array<Record<string, unknown>> = [];
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
const detachedRuntimes: Array<Record<string, unknown>> = [];
const api = {
config: {},
pluginConfig: { workspaceDir: root },
runtime: {
tasks: {
runs: {
bindSession: ({ sessionKey }: { sessionKey: string }) => ({
resolve: (token: string) =>
sessionKey === "agent:main:draft:1780636411666238-3" && token === "turn-1"
? {
taskId: "native-task",
runtime: "acp",
requesterSessionKey: sessionKey,
ownerKey: "draft-1780636411666238-3",
scopeKind: "session",
runId: token,
task: "native",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "state_changes",
createdAt: 1,
}
: undefined,
}),
},
},
},
session: {
state: {
registerSessionExtension: (extension: Record<string, unknown>) => {
sessionExtensions.push(extension);
},
patchSessionExtension: (patch: Record<string, unknown>) => {
sessionExtensionPatches.push(patch);
return { ok: true };
},
},
},
registerDetachedTaskRuntime: (runtime: Record<string, unknown>) => {
detachedRuntimes.push(runtime);
},
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
methods.set(method, handler);
},
registerTool: () => undefined,
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
hooks.set(event, handler);
},
} as unknown as OpenClawPluginApi;
plugin.register(api);
expect(sessionExtensions).toHaveLength(1);
expect(sessionExtensions[0]).toMatchObject({
namespace: "xworkmate",
sessionEntrySlotKey: "xworkmate",
});
const projected = (sessionExtensions[0]?.project as (ctx: Record<string, unknown>) => unknown)({
sessionKey: "agent:main:draft:1780636411666238-3",
state: {},
});
expect(projected).toMatchObject({
appSessionKey: "draft:1780636411666238-3",
openClawSessionKey: "agent:main:draft:1780636411666238-3",
});
expect(detachedRuntimes).toHaveLength(1);
await hooks.get("session.start")?.({
sessionKey: "draft-1780636411666238-3",
openClawSessionKey: "agent:main:draft:1780636411666238-3",
threadId: "draft-1780636411666238-3",
runId: "turn-1",
expectedArtifactDirs: ["artifacts/", "reports/", "exports/"],
});
await fs.promises.mkdir(path.join(root, "reports"), { recursive: true });
await fs.promises.writeFile(path.join(root, "reports", "final.md"), "final");
expect(sessionExtensionPatches).toHaveLength(1);
expect(sessionExtensionPatches[0]).toMatchObject({
key: "agent:main:draft:1780636411666238-3",
pluginId: "openclaw-multi-session-plugins",
namespace: "xworkmate",
value: {
appSessionKey: "draft-1780636411666238-3",
openClawSessionKey: "agent:main:draft:1780636411666238-3",
appThreadId: "draft-1780636411666238-3",
runId: "turn-1",
artifactScope: "tasks/draft-1780636411666238-3/turn-1",
expectedArtifactDirs: ["artifacts/", "reports/", "exports/"],
},
});
const snapshot = await callGatewayMethod(methods, "xworkmate.tasks.get", {
sessionKey: "draft-1780636411666238-3",
runId: "turn-1",
expectedArtifactDirs: ["reports"],
sinceUnixMs: Date.now() - 1_000,
});
expect(snapshot.ok).toBe(true);
expect(snapshot.payload).toMatchObject({
status: "completed",
taskStatus: "succeeded",
sessionKey: "draft-1780636411666238-3",
openClawSessionKey: "agent:main:draft:1780636411666238-3",
appSessionKey: "draft-1780636411666238-3",
artifactCount: 1,
});
expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" });
expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]);
});
it("does not invent default session or run ids for the optional agent tool", async () => { it("does not invent default session or run ids for the optional agent tool", async () => {
const tools: Array<{ tool: unknown; options: unknown }> = []; const tools: Array<{ tool: unknown; options: unknown }> = [];
const api = { const api = {
@ -141,8 +253,6 @@ describe("plugin registration", () => {
registerTool: (tool: unknown, options: unknown) => { registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
registerHook: () => undefined,
} as unknown as OpenClawPluginApi; } as unknown as OpenClawPluginApi;
plugin.register(api); plugin.register(api);
@ -162,7 +272,7 @@ describe("plugin registration", () => {
); );
}); });
it("does not expose session scope controls on the bridge agents tool", async () => { it("does not expose the removed bridge agents tool", async () => {
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = []; const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
const api = { const api = {
config: {}, config: {},
@ -176,147 +286,7 @@ describe("plugin registration", () => {
plugin.register(api); plugin.register(api);
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents")); expect(tools.map((item) => item.options.names).flat()).toEqual(["openclaw_multi_session_artifacts"]);
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
parameters: { properties?: Record<string, unknown> };
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
};
const tool = factory({});
expect(tool.parameters.properties?.sessionKey).toBeUndefined();
expect(tool.parameters.properties?.runId).toBeUndefined();
expect(tool.parameters.properties?.workspaceDir).toBeUndefined();
await expect(tool.execute("call-1", { taskPrompt: "run", steps: [] })).rejects.toThrow("sessionKey required");
await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { taskPrompt: "run", steps: [] })).rejects.toThrow(
"runId required",
);
});
it("fails closed when bridge token is missing", async () => {
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
const api = {
config: {},
pluginConfig: { workspaceDir: await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-agent-token-")), bridgeUrl: "http://127.0.0.1:1" },
registerGatewayMethod: () => undefined,
registerHook: () => undefined,
registerTool: (tool: unknown, options: { names?: string[] }) => {
tools.push({ tool, options });
},
} as unknown as OpenClawPluginApi;
plugin.register(api);
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
};
const tool = factory({ sessionKey: "thread-main", runId: "turn-1" });
await expect(
tool.execute("call-1", {
taskPrompt: "run",
steps: [{ providerId: "codex", prompt: "hello" }],
}),
).rejects.toThrow("bridgeToken required");
});
it("runs bridge-backed multi-agent work inside the current task artifact scope", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-bridge-agents-"));
const bridgeRequests: Array<Record<string, unknown>> = [];
const bridgeServer = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/acp/rpc") {
res.statusCode = 404;
res.end();
return;
}
expect(req.headers.authorization).toBe("Bearer bridge-token");
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString("utf8");
});
req.on("end", () => {
const decoded = JSON.parse(body) as Record<string, unknown>;
bridgeRequests.push(decoded);
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: decoded.id,
result: {
success: true,
status: "completed",
mode: "multi-agent",
orchestrationMode: "sequence",
summary: "bridge agents done",
steps: [{ providerId: "codex", status: "completed", output: "done" }],
},
}),
);
});
});
await new Promise<void>((resolve) => bridgeServer.listen(0, "127.0.0.1", resolve));
try {
const address = bridgeServer.address();
if (!address || typeof address === "string") {
throw new Error("missing bridge server address");
}
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
const api = {
config: {},
pluginConfig: {
workspaceDir: root,
bridgeUrl: `http://127.0.0.1:${address.port}`,
bridgeToken: "bridge-token",
},
registerGatewayMethod: () => undefined,
registerHook: () => undefined,
registerTool: (tool: unknown, options: { names?: string[] }) => {
tools.push({ tool, options });
},
} as unknown as OpenClawPluginApi;
plugin.register(api);
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; details: { artifacts: Array<{ relativePath: string }> } }>;
};
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root });
const result = await tool.execute("call-1", {
taskPrompt: "coordinate",
mode: "sequence",
steps: [{ providerId: "codex", prompt: "hello" }],
sessionKey: "evil",
runId: "evil",
workspaceDir: "/",
});
expect(result.content[0]?.text).toContain("bridge agents done");
expect(result.details.artifacts).toEqual(
expect.arrayContaining([
expect.objectContaining({ relativePath: "multi-agent-result.json" }),
expect.objectContaining({ relativePath: "multi-agent-result.md" }),
]),
);
expect(await fs.promises.readFile(path.join(root, "tasks", "thread-main", "turn-1", "multi-agent-result.md"), "utf8")).toContain(
"bridge agents done",
);
await expect(fs.promises.stat(path.join(root, "tasks", "evil", "evil", "multi-agent-result.md"))).rejects.toThrow();
expect(bridgeRequests).toHaveLength(1);
const params = bridgeRequests[0]?.params as Record<string, unknown>;
expect(params.sessionId).toBe("openclaw:thread-main");
expect(params.threadId).toBe("thread-main");
expect(params.workingDirectory).toBe(await fs.promises.realpath(path.join(root, "tasks", "thread-main", "turn-1")));
expect(params.multiAgent).toBe(true);
expect(params.routing).toMatchObject({
orchestrationMode: "sequence",
steps: [{ providerId: "codex", prompt: "hello" }],
});
} finally {
await new Promise<void>((resolve, reject) => {
bridgeServer.close((error) => (error ? reject(error) : resolve()));
});
}
}); });
it("uses host context scope for the optional agent tool", async () => { it("uses host context scope for the optional agent tool", async () => {
@ -342,8 +312,6 @@ describe("plugin registration", () => {
registerTool: (tool: unknown, options: unknown) => { registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
registerHook: () => undefined,
} as unknown as OpenClawPluginApi; } as unknown as OpenClawPluginApi;
plugin.register(api); plugin.register(api);

185
index.ts
View File

@ -11,7 +11,14 @@ import {
readXWorkmateArtifact, readXWorkmateArtifact,
formatArtifactManifestMarkdown, formatArtifactManifestMarkdown,
} from "./src/exportArtifacts.js"; } from "./src/exportArtifacts.js";
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; import {
createOrUpdateXWorkmateTaskRecord,
createXWorkmateTaskStore,
getXWorkmateTaskSnapshot,
recordXWorkmateSessionMapping,
registerXWorkmateDetachedTaskRuntime,
registerXWorkmateSessionExtension,
} from "./src/taskState.js";
type XWorkmateToolContext = { type XWorkmateToolContext = {
config?: unknown; config?: unknown;
@ -85,21 +92,55 @@ const plugin = {
export default plugin; export default plugin;
function register(api: OpenClawPluginApi) { function register(api: OpenClawPluginApi) {
api.registerHook("session.start", async (event: any) => { const taskStore = createXWorkmateTaskStore();
try { registerXWorkmateSessionExtension(api);
const params = scopedGatewayParams(event?.context ?? event); registerXWorkmateDetachedTaskRuntime(api, taskStore);
if (params.sessionKey && params.runId) {
await prepareXWorkmateArtifacts({ api.registerHook(
params, "session.start",
config: api.config, async (event: any) => {
pluginConfig: api.pluginConfig, try {
}); const params = scopedGatewayParams(event?.context ?? event);
if (params.sessionKey && params.runId) {
createOrUpdateXWorkmateTaskRecord(taskStore, {
params,
status: "running",
progressSummary: "OpenClaw task is running",
});
const prepared = await prepareXWorkmateArtifacts({
params,
config: api.config,
pluginConfig: api.pluginConfig,
});
await recordXWorkmateSessionMapping({
api,
taskStore,
params,
artifactScope: prepared.artifactScope,
});
}
} catch (error) {
api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`);
} }
} catch (e) { },
// Ignored: best-effort preparation { name: "openclaw-multi-session-plugins.session-start" },
);
api.registerGatewayMethod("xworkmate.tasks.get", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await getXWorkmateTaskSnapshot({
api,
taskStore,
params: scopedGatewayParams(opts.params),
});
opts.respond(true, payload, undefined);
} catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
} }
}); });
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const payload = await prepareXWorkmateArtifacts({ const payload = await prepareXWorkmateArtifacts({
@ -175,29 +216,10 @@ function register(api: OpenClawPluginApi) {
}); });
} }
}); });
api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await runXWorkmateBridgeAgents({
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
opts.respond(true, payload, undefined);
} catch (error) {
opts.respond(false, undefined, {
code: "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
}
});
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
names: ["openclaw_multi_session_artifacts"], names: ["openclaw_multi_session_artifacts"],
optional: true, optional: true,
}); });
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
names: ["openclaw_multi_session_agents"],
optional: true,
});
} }
function createXWorkmateArtifactsTool( function createXWorkmateArtifactsTool(
@ -300,102 +322,3 @@ function createXWorkmateArtifactsTool(
}, },
} as unknown as AnyAgentTool; } as unknown as AnyAgentTool;
} }
function createXWorkmateAgentsTool(
api: OpenClawPluginApi,
ctx: XWorkmateToolContext,
): AnyAgentTool {
return {
name: "openclaw_multi_session_agents",
label: "XWorkmate multi-agent bridge",
description:
"Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
taskPrompt: {
type: "string",
description: "Overall multi-agent task prompt.",
},
mode: {
type: "string",
enum: ["sequence", "parallel", "race", "conversation"],
description: "Multi-agent orchestration mode.",
},
steps: {
type: "array",
description: "Agent steps. Each item needs providerId and prompt.",
items: {
type: "object",
additionalProperties: false,
properties: {
providerId: { type: "string" },
prompt: { type: "string" },
outputAs: { type: "string" },
timeoutMs: { type: "number" },
},
required: ["providerId", "prompt"],
},
},
participants: {
type: "array",
description: "Conversation participants by providerId.",
items: { type: "string" },
},
maxTurns: {
type: "number",
description: "Maximum turns for conversation mode.",
},
stopConditions: {
type: "array",
description: "Text markers that stop conversation mode.",
items: { type: "string" },
},
timeoutMs: {
type: "number",
description: "Overall bridge request timeout.",
},
},
required: ["taskPrompt"],
},
async execute(_id: string, params: Record<string, unknown>) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const {
sessionKey: _ignoredSessionKey,
runId: _ignoredRunId,
workspaceDir: _ignoredWorkspaceDir,
...operationParams
} = params;
const payload = await runXWorkmateBridgeAgents({
params: {
...operationParams,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
},
config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig,
});
const summary = typeof payload.bridgeResult.summary === "string"
? payload.bridgeResult.summary
: typeof payload.bridgeResult.output === "string"
? payload.bridgeResult.output
: "Multi-agent run completed.";
return {
content: [{ type: "text", text: [summary, "", formatArtifactManifestMarkdown(payload)].join("\n") }],
details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult },
};
},
} as unknown as AnyAgentTool;
}

View File

@ -6,8 +6,8 @@
"onStartup": true "onStartup": true
}, },
"contracts": { "contracts": {
"tools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"], "tools": ["openclaw_multi_session_artifacts"],
"sessionScopedTools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"] "sessionScopedTools": ["openclaw_multi_session_artifacts"]
}, },
"configSchema": { "configSchema": {
"type": "object", "type": "object",
@ -28,18 +28,6 @@
"artifactRefSigningSecret": { "artifactRefSigningSecret": {
"type": "string", "type": "string",
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret." "description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
},
"bridgeUrl": {
"type": "string",
"description": "XWorkmate Bridge base URL or /acp/rpc URL used by openclaw_multi_session_agents."
},
"bridgeToken": {
"type": "string",
"description": "Bearer token used when openclaw_multi_session_agents calls XWorkmate Bridge."
},
"bridgeTimeoutMs": {
"type": "number",
"description": "Default timeout for XWorkmate Bridge multi-agent calls."
} }
} }
}, },
@ -60,19 +48,6 @@
"label": "Artifact Ref Signing Secret", "label": "Artifact Ref Signing Secret",
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.", "help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
"sensitive": true "sensitive": true
},
"bridgeUrl": {
"label": "Bridge URL",
"help": "XWorkmate Bridge base URL or /acp/rpc URL for multi-agent orchestration."
},
"bridgeToken": {
"label": "Bridge Token",
"help": "Bearer token for XWorkmate Bridge multi-agent orchestration.",
"sensitive": true
},
"bridgeTimeoutMs": {
"label": "Bridge Timeout",
"help": "Timeout in milliseconds for multi-agent bridge calls."
} }
} }
} }

View File

@ -1,249 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
exportXWorkmateArtifacts,
prepareXWorkmateArtifacts,
type XWorkmateArtifactExport,
} from "./exportArtifacts.js";
type BridgeAgentInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
type BridgeAgentRun = XWorkmateArtifactExport & {
bridgeResult: Record<string, unknown>;
};
export async function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const runId = requiredString(params.runId, "runId required");
const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required");
const bridgeUrl = bridgeRpcUrl(pluginConfig);
const bridgeToken = bridgeAuthToken(pluginConfig);
if (!bridgeToken) {
throw new Error("bridgeToken required");
}
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey, runId, workspaceDir: params.workspaceDir },
config: input.config,
pluginConfig,
});
const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence";
const participants = safeStringList(params.participants);
const steps = safeSteps(params.steps, participants.length > 0);
if (steps.length === 0 && participants.length === 0) {
throw new Error("steps or participants required");
}
const routing: Record<string, unknown> = {
orchestrationMode,
steps,
};
if (participants.length > 0) {
routing.participants = participants;
}
const maxTurns = positiveInteger(params.maxTurns, 0);
if (maxTurns > 0) {
routing.maxTurns = maxTurns;
}
const stopConditions = safeStringList(params.stopConditions);
if (stopConditions.length > 0) {
routing.stopConditions = stopConditions;
}
const bridgeResult = await callBridgeRPC({
bridgeUrl,
bridgeToken,
timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)),
body: {
jsonrpc: "2.0",
id: `openclaw-${Date.now()}`,
method: "session.start",
params: {
sessionId: `openclaw:${sessionKey}`,
threadId: sessionKey,
taskPrompt,
workingDirectory: prepared.artifactDirectory,
multiAgent: true,
mode: "multi-agent",
routing,
},
},
});
await fs.mkdir(prepared.artifactDirectory, { recursive: true });
await fs.writeFile(
path.join(prepared.artifactDirectory, "multi-agent-result.json"),
`${JSON.stringify(bridgeResult, null, 2)}\n`,
);
await fs.writeFile(
path.join(prepared.artifactDirectory, "multi-agent-result.md"),
formatBridgeResultMarkdown(bridgeResult),
);
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey,
runId,
workspaceDir: params.workspaceDir,
artifactScope: prepared.artifactScope,
includeContent: false,
},
config: input.config,
pluginConfig,
});
return { ...exported, bridgeResult };
}
async function callBridgeRPC(input: {
bridgeUrl: string;
bridgeToken: string;
timeoutMs: number;
body: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
try {
const response = await fetch(input.bridgeUrl, {
method: "POST",
headers: {
Authorization: bearer(input.bridgeToken),
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input.body),
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`bridge request failed (${response.status}): ${text.trim()}`);
}
const decoded = JSON.parse(text) as Record<string, unknown>;
const error = asRecord(decoded.error);
if (error) {
throw new Error(optionalString(error.message) || "bridge rpc error");
}
const result = asRecord(decoded.result);
if (!result) {
throw new Error("bridge response missing result");
}
return result;
} finally {
clearTimeout(timer);
}
}
function bridgeRpcUrl(pluginConfig: Record<string, unknown>): string {
const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL);
if (!configured) {
throw new Error("bridgeUrl required");
}
const trimmed = configured.replace(/\/+$/, "");
if (trimmed.endsWith("/acp/rpc")) {
return trimmed;
}
return `${trimmed}/acp/rpc`;
}
function bridgeAuthToken(pluginConfig: Record<string, unknown>): string {
return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN);
}
function safeSteps(raw: unknown, allowEmpty: boolean): Array<Record<string, unknown>> {
if (!Array.isArray(raw)) {
if (allowEmpty) {
return [];
}
throw new Error("steps required");
}
return raw.map((item, index) => {
const mapped = asRecord(item);
if (!mapped) {
throw new Error(`steps[${index}] must be an object`);
}
const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent);
const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt);
if (!providerId) {
throw new Error(`steps[${index}].providerId required`);
}
if (!prompt) {
throw new Error(`steps[${index}].prompt required`);
}
return {
providerId,
prompt,
...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}),
...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}),
};
});
}
function safeStringList(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.map((value) => optionalString(value)).filter((value) => value.length > 0);
}
function formatBridgeResultMarkdown(result: Record<string, unknown>): string {
const lines = ["# Multi-Agent Result", ""];
lines.push(`- Status: ${optionalString(result.status) || "unknown"}`);
lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`);
const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message);
if (summary) {
lines.push("", "## Summary", "", summary);
}
const steps = Array.isArray(result.steps) ? result.steps : [];
if (steps.length > 0) {
lines.push("", "## Steps", "");
for (const item of steps) {
const step = asRecord(item) ?? {};
lines.push(
`- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${
optionalString(step.error) ? ` (${optionalString(step.error)})` : ""
}`,
);
}
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function bearer(token: string): string {
return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
}
function requiredString(value: unknown, message: string): string {
const text = optionalString(value);
if (!text) {
throw new Error(message);
}
return text;
}
function optionalString(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return "";
}
const text = String(value).trim();
return text === "<nil>" ? "" : text;
}
function positiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.floor(parsed);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}

View File

@ -325,6 +325,62 @@ describe("exportXWorkmateArtifacts", () => {
await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow(); await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow();
}); });
it("exports explicitly expected artifact dirs when the task scope is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(root, "assets", "images"), { recursive: true });
await fs.mkdir(path.join(root, "reports"), { recursive: true });
await fs.writeFile(path.join(root, "assets", "images", "cover.png"), "png");
await fs.writeFile(path.join(root, "reports", "final.md"), "final");
await fs.writeFile(path.join(root, "scratch.txt"), "scratch");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft-article",
runId: "openclaw-run-1",
artifactScope: prepared.artifactScope,
expectedArtifactDirs: ["assets/images", "reports"],
sinceUnixMs: Date.now() - 1_000,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([
"assets/images/cover.png",
"reports/final.md",
]);
expect(result.artifacts.every((entry) => entry.artifactScope === prepared.artifactScope)).toBe(true);
expect(result.artifacts.every((entry) => entry.scopeKind === "task")).toBe(true);
});
it("keeps scoped artifacts authoritative over expected artifact dirs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "scoped.md"), "scoped");
await fs.mkdir(path.join(root, "reports"), { recursive: true });
await fs.writeFile(path.join(root, "reports", "root.md"), "root");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft-article",
runId: "openclaw-run-1",
artifactScope: prepared.artifactScope,
expectedArtifactDirs: ["reports"],
sinceUnixMs: Date.now() - 1_000,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/scoped.md"]);
});
it("does not adopt old workspace root files into a later task scope", async () => { it("does not adopt old workspace root files into a later task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({ await prepareXWorkmateArtifacts({

460
src/taskState.ts Normal file
View File

@ -0,0 +1,460 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
type XWorkmateTaskRecord = {
taskId: string;
runtime: "acp";
taskKind: "xworkmate-openclaw";
requesterSessionKey: string;
ownerKey: string;
scopeKind: "session";
runId: string;
label: string;
task: string;
status: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "lost";
deliveryStatus: "pending" | "delivered" | "session_queued" | "failed" | "parent_missing" | "not_applicable";
notifyPolicy: "done_only" | "state_changes" | "silent";
createdAt: number;
startedAt?: number;
endedAt?: number;
lastEventAt?: number;
error?: string;
progressSummary?: string;
terminalSummary?: string;
terminalOutcome?: "succeeded" | "blocked";
};
type XWorkmateSessionMapping = {
appSessionKey: string;
openClawSessionKey: string;
appThreadId?: string;
sessionId?: string;
runId: string;
artifactScope?: string;
expectedArtifactDirs?: string[];
};
export type XWorkmateTaskStore = {
records: Map<string, XWorkmateTaskRecord>;
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
};
const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export function createXWorkmateTaskStore(): XWorkmateTaskStore {
return {
records: new Map(),
sessionMappingsByAppKey: new Map(),
sessionMappingsByOpenClawKey: new Map(),
};
}
export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) {
const registerExtension = api.session?.state?.registerSessionExtension ?? (api as any).registerSessionExtension;
if (typeof registerExtension !== "function") {
return;
}
registerExtension({
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.",
sessionEntrySlotKey: "xworkmate",
project: (ctx: { sessionKey: string; sessionId?: string; state?: unknown }) => {
const state = asRecord(ctx.state) ?? {};
const appSessionKey =
optionalString(state.appSessionKey) ||
optionalString(state.appThreadId) ||
optionalString(state.threadId) ||
appSessionKeyFromOpenClawSessionKey(ctx.sessionKey);
const openClawSessionKey = optionalString(state.openClawSessionKey) || ctx.sessionKey;
return {
...state,
appSessionKey,
openClawSessionKey,
sessionId: optionalString(state.sessionId) || optionalString(ctx.sessionId),
};
},
});
}
export async function recordXWorkmateSessionMapping(input: {
api: OpenClawPluginApi;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
artifactScope?: string;
}) {
const appSessionKey = requiredString(input.params.sessionKey || input.params.appSessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const openClawSessionKey =
optionalString(input.params.openClawSessionKey) ||
optionalString(input.params.openClawSessionId) ||
agentMainSessionKeyFor(appSessionKey);
const expectedArtifactDirs = stringList(input.params.expectedArtifactDirs);
const mapping: XWorkmateSessionMapping = compactObject({
appSessionKey,
openClawSessionKey,
appThreadId: optionalString(input.params.threadId) || appSessionKey,
sessionId: optionalString(input.params.sessionId),
runId,
artifactScope: input.artifactScope || optionalString(input.params.artifactScope),
expectedArtifactDirs: expectedArtifactDirs.length > 0 ? expectedArtifactDirs : undefined,
}) as XWorkmateSessionMapping;
input.taskStore.sessionMappingsByAppKey.set(appSessionKey, mapping);
input.taskStore.sessionMappingsByOpenClawKey.set(openClawSessionKey, mapping);
const patchSessionExtension = resolvePatchSessionExtension(input.api);
if (!patchSessionExtension) {
// Legacy fallback owner: this plugin. Scope: tests and OpenClaw hosts that do not expose
// session extension patching yet. Exit: remove this map once 2026.6.1+ hosts expose the patch
// method on the public plugin API in all supported deployments.
return;
}
await patchSessionExtension({
key: openClawSessionKey,
sessionKey: openClawSessionKey,
pluginId: XWORKMATE_PLUGIN_ID,
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
value: mapping,
});
}
export function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore) {
const registerRuntime = (api as any).registerDetachedTaskRuntime;
if (typeof registerRuntime !== "function") {
return;
}
registerRuntime({
createQueuedTaskRun: (params: Record<string, unknown>) =>
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }),
createRunningTaskRun: (params: Record<string, unknown>) =>
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }),
startTaskRunByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }),
recordTaskRunProgressByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
lastEventAt: Date.now(),
progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary),
}),
finalizeTaskRunByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)),
completeTaskRunByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: "succeeded",
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
terminalOutcome: "succeeded",
}),
failTaskRunByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: taskStatusFrom(params.status, "failed"),
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
}),
setDetachedTaskDeliveryStatusByRunId: (params: Record<string, unknown>) =>
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"),
error: optionalString(params.error),
}),
cancelDetachedTaskRunById: async (params: Record<string, unknown>) => {
const taskId = optionalString(params.taskId);
const record = taskId ? findXWorkmateTaskByTaskId(taskStore, taskId) : undefined;
if (!record) {
return { found: false, cancelled: false };
}
record.status = "cancelled";
record.endedAt = Date.now();
record.lastEventAt = record.endedAt;
return { found: true, cancelled: true, reason: optionalString(params.reason), task: record };
},
});
}
export async function getXWorkmateTaskSnapshot(input: {
api: OpenClawPluginApi;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const sessionKey = requiredString(input.params.sessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const mapping = resolveSessionMapping(input.taskStore, input.params, sessionKey);
const openClawSessionKey =
mapping?.openClawSessionKey || optionalString(input.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey);
const appSessionKey = mapping?.appSessionKey || sessionKey;
const nativeTask = resolveNativeTask(input.api, openClawSessionKey, runId) || resolveNativeTask(input.api, sessionKey, runId);
const storedTask = findXWorkmateTask(input.taskStore, sessionKey, runId);
const exported = await exportXWorkmateArtifacts({
params: input.params,
config: input.api.config,
pluginConfig: input.api.pluginConfig,
});
const task = nativeTask || storedTask;
const taskStatus = normalizeTaskStatus(optionalString((task as any).status), exported.artifacts.length > 0);
if (storedTask && taskStatus === "succeeded" && storedTask.status !== "succeeded") {
storedTask.status = "succeeded";
storedTask.endedAt = Date.now();
storedTask.lastEventAt = storedTask.endedAt;
storedTask.terminalOutcome = "succeeded";
}
return {
success: true,
status: appStatusFromTaskStatus(taskStatus),
taskStatus,
mode: "gateway-chat",
sessionKey,
openClawSessionKey,
appSessionKey,
runId,
task,
artifactScope: exported.artifactScope,
remoteWorkingDirectory: exported.remoteWorkingDirectory,
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
scopeKind: exported.scopeKind,
artifacts: exported.artifacts,
warnings: exported.warnings,
artifactCount: exported.artifacts.length,
};
}
export function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: {
params: Record<string, unknown>;
status: XWorkmateTaskRecord["status"];
progressSummary?: string;
}): XWorkmateTaskRecord {
const sessionKey = requiredString(options.params.sessionKey || options.params.requesterSessionKey, "sessionKey required");
const runId = requiredString(options.params.runId, "runId required");
const key = taskRecordKey(sessionKey, runId);
const now = Date.now();
const existing = input.records.get(key);
if (existing) {
existing.status = options.status;
existing.lastEventAt = now;
if (options.status === "running" && !existing.startedAt) {
existing.startedAt = now;
}
if (options.progressSummary) {
existing.progressSummary = options.progressSummary;
}
return existing;
}
const record: XWorkmateTaskRecord = {
taskId: `xworkmate:${safeTaskIdSegment(sessionKey)}:${safeTaskIdSegment(runId)}`,
runtime: "acp",
taskKind: "xworkmate-openclaw",
requesterSessionKey: optionalString(options.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey),
ownerKey: sessionKey,
scopeKind: "session",
runId,
label: optionalString(options.params.label) || "XWorkmate OpenClaw task",
task: optionalString(options.params.taskPrompt) || optionalString(options.params.task) || "XWorkmate OpenClaw task",
status: options.status,
deliveryStatus: "pending",
notifyPolicy: "state_changes",
createdAt: now,
startedAt: options.status === "running" ? now : undefined,
lastEventAt: now,
progressSummary: options.progressSummary,
};
input.records.set(key, record);
return record;
}
function updateXWorkmateTaskRecordsByRunId(
input: XWorkmateTaskStore,
params: Record<string, unknown>,
patch: Partial<XWorkmateTaskRecord>,
): XWorkmateTaskRecord[] {
const runId = optionalString(params.runId);
const sessionKey = optionalString(params.sessionKey || params.requesterSessionKey);
const records = [...input.records.values()].filter((record) => {
if (runId && record.runId !== runId) {
return false;
}
if (sessionKey && record.ownerKey !== sessionKey && record.requesterSessionKey !== sessionKey) {
return false;
}
return true;
});
for (const record of records) {
Object.assign(record, compactObject(patch));
}
return records;
}
function resolveNativeTask(api: OpenClawPluginApi, sessionKey: string, runId: string): Record<string, unknown> | undefined {
try {
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey });
const resolved = bound?.resolve?.(runId) || bound?.get?.(runId);
return asRecord(resolved);
} catch (error) {
api.logger?.warn?.(
`xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`,
);
return undefined;
}
}
function resolveSessionMapping(
input: XWorkmateTaskStore,
params: Record<string, unknown>,
sessionKey: string,
): XWorkmateSessionMapping | undefined {
const explicitOpenClawKey = optionalString(params.openClawSessionKey);
if (explicitOpenClawKey) {
const byOpenClaw = input.sessionMappingsByOpenClawKey.get(explicitOpenClawKey);
if (byOpenClaw) {
return byOpenClaw;
}
}
return input.sessionMappingsByAppKey.get(sessionKey) || input.sessionMappingsByOpenClawKey.get(sessionKey);
}
function findXWorkmateTask(input: XWorkmateTaskStore, sessionKey: string, runId: string): XWorkmateTaskRecord | undefined {
return input.records.get(taskRecordKey(sessionKey, runId));
}
function findXWorkmateTaskByTaskId(input: XWorkmateTaskStore, taskId: string): XWorkmateTaskRecord | undefined {
return [...input.records.values()].find((record) => record.taskId === taskId);
}
function taskRecordKey(sessionKey: string, runId: string): string {
return `${sessionKey}\u0000${runId}`;
}
function appSessionKeyFromOpenClawSessionKey(sessionKey: string): string {
return sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
}
function agentMainSessionKeyFor(sessionKey: string): string {
return sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
}
function terminalPatch(params: Record<string, unknown>): Partial<XWorkmateTaskRecord> {
const status = taskStatusFrom(params.status, "succeeded");
return {
status,
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
progressSummary: optionalString(params.progressSummary),
terminalSummary: optionalString(params.terminalSummary),
terminalOutcome: status === "succeeded" ? "succeeded" : "blocked",
};
}
function normalizeTaskStatus(status: string, hasArtifacts: boolean): XWorkmateTaskRecord["status"] {
const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running");
if (normalized === "running" && hasArtifacts) {
return "succeeded";
}
return normalized;
}
function appStatusFromTaskStatus(status: XWorkmateTaskRecord["status"]): string {
if (status === "succeeded") {
return "completed";
}
if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") {
return "failed";
}
return "running";
}
function taskStatusFrom(value: unknown, fallback: XWorkmateTaskRecord["status"]): XWorkmateTaskRecord["status"] {
const status = optionalString(value);
if (
status === "queued" ||
status === "running" ||
status === "succeeded" ||
status === "failed" ||
status === "timed_out" ||
status === "cancelled" ||
status === "lost"
) {
return status;
}
return fallback;
}
function deliveryStatusFrom(value: unknown, fallback: XWorkmateTaskRecord["deliveryStatus"]): XWorkmateTaskRecord["deliveryStatus"] {
const status = optionalString(value);
if (
status === "pending" ||
status === "delivered" ||
status === "session_queued" ||
status === "failed" ||
status === "parent_missing" ||
status === "not_applicable"
) {
return status;
}
return fallback;
}
function resolvePatchSessionExtension(api: OpenClawPluginApi):
| ((params: Record<string, unknown>) => Promise<unknown> | unknown)
| undefined {
const stateApi = (api.session?.state ?? {}) as Record<string, unknown>;
const apiRecord = api as unknown as Record<string, unknown>;
const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension;
return typeof candidate === "function"
? (candidate as (params: Record<string, unknown>) => Promise<unknown> | unknown)
: undefined;
}
function requiredString(value: unknown, message: string): string {
const text = optionalString(value);
if (!text) {
throw new Error(message);
}
return text;
}
function optionalString(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return "";
}
const text = String(value).trim();
return text === "<nil>" ? "" : text;
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
const text = optionalString(entry);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function numberOrNow(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== "")) as Partial<T>;
}
function safeTaskIdSegment(value: string): string {
return value.replace(/[^A-Za-z0-9._:-]+/g, "_");
}

View File

@ -7,5 +7,5 @@
"outDir": "dist", "outDir": "dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["index.ts", "src/exportArtifacts.ts", "src/bridgeAgents.ts"] "include": ["index.ts", "src/exportArtifacts.ts"]
} }