Adapt plugin to OpenClaw 2026.5.28

This commit is contained in:
Haitao Pan 2026-06-01 10:54:17 +08:00
parent 260380531b
commit 9bc52e7861
9 changed files with 393 additions and 3227 deletions

View File

@ -67,10 +67,11 @@ Equivalent config shape for a linked checkout:
## Contract ## Contract
Prepare request params are supplied by the OpenClaw host, bridge, or APP Prepare request params are supplied by the OpenClaw host, bridge, or APP
runtime. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the runtime. On OpenClaw runtimes that expose a trusted plugin `sessionScope`, the
trusted mapping into OpenClaw's built-in multi-session model; it does not parse plugin uses that scope first. Otherwise it falls back to bridge/app runtime
paths from chat text and does not invent fallback session/run identities. params. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
Gateway methods accept these fields from bridge/app runtime params. The optional mapping into OpenClaw's built-in multi-session model; it does not parse paths
from chat text and does not invent fallback session/run identities. The optional
agent tool does not expose these fields to the model; it only uses host-injected agent tool does not expose these fields to the model; it only uses host-injected
tool context. tool context.
@ -139,15 +140,13 @@ 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`. If `sinceUnixMs` is provided, export also derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task
adopts files created or changed in the workspace root during the current run by scope. The plugin does not adopt files from the workspace root; agents must
copying them into that task scope before returning the manifest. This covers write final deliverables directly into the prepared `artifactDirectory`.
agents that save output as `./file.md` while still keeping XWorkmate sync scoped
to `tasks/<session>/<run>`.
Without `sinceUnixMs`, export/list only reads the current task scope. The plugin The plugin never scans workspace root, `owners/*/threads/*`, or any previous
never scans `tasks/`, `owners/*/threads/*`, or any previous thread workspace as thread workspace as a fallback and does not borrow artifacts from earlier task
a fallback and does not borrow artifacts from earlier task scopes. 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
@ -205,9 +204,9 @@ only remote file access path.
## Limits ## Limits
- Only files inside the resolved OpenClaw workspace are exported. - Only files inside the resolved OpenClaw workspace are exported.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, 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.
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading. - `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively.
- Export never adopts files from OpenClaw owner/thread workspaces; agents must write into the prepared task scope or into the current-run workspace root for timestamp-gated adoption. - Export never adopts files from the workspace root or OpenClaw owner/thread workspaces; agents must write into the prepared task scope.
- 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>`.

47
dist/index.js vendored
View File

@ -1,5 +1,34 @@
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js"; import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
const runScope = resolveRunScope({ sessionScope });
if (!runScope) {
return params;
}
return {
...params,
sessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
}
function resolveRunScope(ctx) {
const scope = ctx.sessionScope;
const sessionKey = scope?.sessionKey || ctx.sessionKey;
const runId = scope?.runId || ctx.runId || "";
if (!sessionKey || !runId) {
return undefined;
}
return {
sessionKey,
runId,
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
};
}
const plugin = { const plugin = {
id: "openclaw-multi-session-plugins", id: "openclaw-multi-session-plugins",
name: "openclaw-multi-session-plugins", name: "openclaw-multi-session-plugins",
@ -11,7 +40,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => { api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
try { try {
const payload = await prepareXWorkmateArtifacts({ const payload = await prepareXWorkmateArtifacts({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -27,7 +56,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => { api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => {
try { try {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -43,7 +72,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => { api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => {
try { try {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
params: { ...opts.params, includeContent: false }, params: { ...scopedGatewayParams(opts.params), includeContent: false },
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -59,7 +88,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts) => { api.registerGatewayMethod("xworkmate.artifacts.read", async (opts) => {
try { try {
const payload = await readXWorkmateArtifact({ const payload = await readXWorkmateArtifact({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -75,7 +104,7 @@ function register(api) {
api.registerGatewayMethod("xworkmate.agents.run", async (opts) => { api.registerGatewayMethod("xworkmate.agents.run", async (opts) => {
try { try {
const payload = await runXWorkmateBridgeAgents({ const payload = await runXWorkmateBridgeAgents({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -140,21 +169,23 @@ function createXWorkmateArtifactsTool(api, ctx) {
}, },
async execute(_id, params) { async execute(_id, params) {
const action = typeof params.action === "string" ? params.action : ""; const action = typeof params.action === "string" ? params.action : "";
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || ""; const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) { if (!sessionKey) {
throw new Error("sessionKey required"); throw new Error("sessionKey required");
} }
if (!runId) { if (!runId) {
throw new Error("runId required"); throw new Error("runId required");
} }
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params; const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const baseParams = { const baseParams = {
...operationParams, ...operationParams,
sessionKey, sessionKey,
runId, runId,
...(workspaceDir ? { workspaceDir } : {}), ...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
}; };
if (action === "list") { if (action === "list") {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
@ -241,15 +272,16 @@ function createXWorkmateAgentsTool(api, ctx) {
required: ["taskPrompt"], required: ["taskPrompt"],
}, },
async execute(_id, params) { async execute(_id, params) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || ""; const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) { if (!sessionKey) {
throw new Error("sessionKey required"); throw new Error("sessionKey required");
} }
if (!runId) { if (!runId) {
throw new Error("runId required"); throw new Error("runId required");
} }
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params; const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const payload = await runXWorkmateBridgeAgents({ const payload = await runXWorkmateBridgeAgents({
params: { params: {
@ -257,6 +289,7 @@ function createXWorkmateAgentsTool(api, ctx) {
sessionKey, sessionKey,
runId, runId,
...(workspaceDir ? { workspaceDir } : {}), ...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
}, },
config: ctx.config ?? api.config, config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,

View File

@ -14,8 +14,6 @@ const SKIPPED_DIRS = new Set([
".dart_tool", ".dart_tool",
".next", ".next",
".turbo", ".turbo",
"build",
"dist",
"node_modules", "node_modules",
]); ]);
export async function prepareXWorkmateArtifacts(input) { export async function prepareXWorkmateArtifacts(input) {
@ -85,22 +83,11 @@ export async function exportXWorkmateArtifacts(input) {
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
skipTaskScopeRoot: false,
warnSkippedSymlinks: true, warnSkippedSymlinks: true,
warnings, warnings,
}) })
: []; : [];
const adoptedCandidates = sinceUnixMs > 0 const candidates = scopedCandidates;
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
if (!scopePrepared && candidates.length === 0) { if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run"); warnings.push("artifact scope is not prepared for this task run");
} }
@ -297,45 +284,6 @@ export function formatArtifactManifestMarkdown(input) {
} }
return lines.join("\n"); return lines.join("\n");
} }
async function adoptWorkspaceRootCandidatesIntoScope(input) {
const rootCandidates = await collectCandidates({
scanRoot: input.workspaceRoot,
relativeRoot: input.workspaceRoot,
sinceUnixMs: input.sinceUnixMs,
skipTaskScopeRoot: true,
warnSkippedSymlinks: false,
warnings: input.warnings,
});
const adopted = [];
for (const candidate of rootCandidates) {
if (input.existingRelativePaths.has(candidate.relativePath)) {
continue;
}
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
if (!isWithinRoot(input.scopeRoot, targetPath)) {
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
continue;
}
if (await fileExists(targetPath)) {
input.existingRelativePaths.add(candidate.relativePath);
continue;
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(candidate.absolutePath, targetPath);
const stat = await fs.stat(targetPath);
const realPath = await fs.realpath(targetPath);
adopted.push({
absolutePath: realPath,
relativePath: candidate.relativePath,
sizeBytes: stat.size,
mtimeMs: candidate.mtimeMs,
artifactScope: input.artifactScope,
scopeKind: "task",
});
input.existingRelativePaths.add(candidate.relativePath);
}
return adopted;
}
async function collectCandidates(input) { async function collectCandidates(input) {
const candidates = []; const candidates = [];
await walk(input.scanRoot); await walk(input.scanRoot);
@ -362,9 +310,6 @@ async function collectCandidates(input) {
continue; continue;
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) { if (SKIPPED_DIRS.has(entry.name)) {
continue; continue;
} }
@ -419,6 +364,7 @@ function safeScopeSegment(value) {
.trim() .trim()
.replace(/[\\/]+/g, "_") .replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_") .replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "") .replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 96) || "scope"; .slice(0, 96) || "scope";
} }
@ -468,15 +414,6 @@ async function directoryExists(absolutePath) {
return false; return false;
} }
} }
async function fileExists(absolutePath) {
try {
const stat = await fs.stat(absolutePath);
return stat.isFile();
}
catch {
return false;
}
}
function safeArtifactRefRunScope(value) { function safeArtifactRefRunScope(value) {
try { try {
return safeTaskArtifactScope(value); return safeTaskArtifactScope(value);

View File

@ -339,7 +339,15 @@ describe("plugin registration", () => {
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => { const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>; execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
}; };
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root }); const tool = factory({
sessionScope: {
scopeKind: "run",
sessionKey: "thread-main",
runId: "turn-1",
workspaceDir: root,
relativeTaskDirectory: "tasks/thread-main/turn-1",
},
});
const result = await tool.execute("call-1", { const result = await tool.execute("call-1", {
action: "list", action: "list",
sessionKey: "thread-other", sessionKey: "thread-other",

View File

@ -3,6 +3,7 @@ import type {
GatewayRequestHandlerOptions, GatewayRequestHandlerOptions,
OpenClawPluginApi, OpenClawPluginApi,
} from "openclaw/plugin-sdk/core"; } from "openclaw/plugin-sdk/core";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { import {
exportXWorkmateArtifacts, exportXWorkmateArtifacts,
prepareXWorkmateArtifacts, prepareXWorkmateArtifacts,
@ -15,13 +16,63 @@ type XWorkmateToolContext = {
workspaceDir?: string; workspaceDir?: string;
sessionKey?: string; sessionKey?: string;
runId?: string; runId?: string;
sessionScope?: { sessionScope?: XWorkmatePluginSessionScope;
};
type XWorkmatePluginSessionScope = {
scopeKind?: "global" | "session" | "run";
sessionKey?: string; sessionKey?: string;
runId?: string; runId?: string;
workspaceDir?: string; workspaceDir?: string;
relativeTaskDirectory?: string;
}; };
type XWorkmateResolvedRunScope = {
sessionKey: string;
runId: string;
workspaceDir?: string;
artifactScope?: string;
}; };
type XWorkmateGatewayRequestScope = {
sessionScope?: XWorkmatePluginSessionScope;
};
function scopedGatewayParams(params: Record<string, unknown>): Record<string, unknown> {
const sessionScope = (getPluginRuntimeGatewayRequestScope() as XWorkmateGatewayRequestScope | undefined)?.sessionScope;
const runScope = resolveRunScope({ sessionScope });
if (!runScope) {
return params;
}
return {
...params,
sessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
};
}
function resolveRunScope(ctx: {
sessionScope?: XWorkmatePluginSessionScope;
sessionKey?: string;
runId?: string;
workspaceDir?: string;
}): XWorkmateResolvedRunScope | undefined {
const scope = ctx.sessionScope;
const sessionKey = scope?.sessionKey || ctx.sessionKey;
const runId = scope?.runId || ctx.runId || "";
if (!sessionKey || !runId) {
return undefined;
}
return {
sessionKey,
runId,
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
};
}
const plugin = { const plugin = {
id: "openclaw-multi-session-plugins", id: "openclaw-multi-session-plugins",
name: "openclaw-multi-session-plugins", name: "openclaw-multi-session-plugins",
@ -35,7 +86,7 @@ function register(api: OpenClawPluginApi) {
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({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -50,7 +101,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -65,7 +116,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
params: { ...opts.params, includeContent: false }, params: { ...scopedGatewayParams(opts.params), includeContent: false },
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -80,7 +131,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.artifacts.read", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const payload = await readXWorkmateArtifact({ const payload = await readXWorkmateArtifact({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -95,7 +146,7 @@ function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const payload = await runXWorkmateBridgeAgents({ const payload = await runXWorkmateBridgeAgents({
params: opts.params, params: scopedGatewayParams(opts.params),
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
@ -164,15 +215,16 @@ function createXWorkmateArtifactsTool(
}, },
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const action = typeof params.action === "string" ? params.action : ""; const action = typeof params.action === "string" ? params.action : "";
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || ""; const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) { if (!sessionKey) {
throw new Error("sessionKey required"); throw new Error("sessionKey required");
} }
if (!runId) { if (!runId) {
throw new Error("runId required"); throw new Error("runId required");
} }
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { const {
sessionKey: _ignoredSessionKey, sessionKey: _ignoredSessionKey,
runId: _ignoredRunId, runId: _ignoredRunId,
@ -184,6 +236,7 @@ function createXWorkmateArtifactsTool(
sessionKey, sessionKey,
runId, runId,
...(workspaceDir ? { workspaceDir } : {}), ...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
}; };
if (action === "list") { if (action === "list") {
const payload = await exportXWorkmateArtifacts({ const payload = await exportXWorkmateArtifacts({
@ -275,15 +328,16 @@ function createXWorkmateAgentsTool(
required: ["taskPrompt"], required: ["taskPrompt"],
}, },
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const runScope = resolveRunScope(ctx);
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || ""; const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) { if (!sessionKey) {
throw new Error("sessionKey required"); throw new Error("sessionKey required");
} }
if (!runId) { if (!runId) {
throw new Error("runId required"); throw new Error("runId required");
} }
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { const {
sessionKey: _ignoredSessionKey, sessionKey: _ignoredSessionKey,
runId: _ignoredRunId, runId: _ignoredRunId,
@ -296,6 +350,7 @@ function createXWorkmateAgentsTool(
sessionKey, sessionKey,
runId, runId,
...(workspaceDir ? { workspaceDir } : {}), ...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
}, },
config: ctx.config ?? api.config, config: ctx.config ?? api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,

View File

@ -1,6 +1,6 @@
{ {
"name": "openclaw-multi-session-plugins", "name": "openclaw-multi-session-plugins",
"version": "0.1.13", "version": "0.1.14",
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts", "description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -44,10 +44,9 @@
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"openclaw": "2026.5.3-1", "openclaw": "2026.5.28",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },

3197
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,17 @@ describe("exportXWorkmateArtifacts", () => {
expect(first.scopeKind).toBe("task"); expect(first.scopeKind).toBe("task");
}); });
it("normalizes task scope segments like the OpenClaw session scope runtime", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "agent::main:main", runId: "run alpha" },
pluginConfig: { workspaceDir: root },
});
expect(prepared.artifactScope).toBe("tasks/agent_main_main/run_alpha");
});
it("exports changed files with metadata and base64 content", async () => { it("exports changed files with metadata and base64 content", 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-"));
const prepared = await prepareXWorkmateArtifacts({ const prepared = await prepareXWorkmateArtifacts({
@ -149,6 +160,32 @@ describe("exportXWorkmateArtifacts", () => {
}); });
}); });
it("exports nested dist and build deliverables inside the task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "dist"), { recursive: true });
await fs.mkdir(path.join(prepared.artifactDirectory, "build", "assets"), { recursive: true });
await fs.writeFile(path.join(prepared.artifactDirectory, "dist", "final.pdf"), "pdf");
await fs.writeFile(path.join(prepared.artifactDirectory, "build", "assets", "cover.png"), "png");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
maxInlineBytes: 0,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
"build/assets/cover.png",
"dist/final.pdf",
]);
});
it("uses the current task scope when artifactScope is omitted", async () => { it("uses the current task scope when artifactScope is omitted", 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-"));
const current = await prepareXWorkmateArtifacts({ const current = await prepareXWorkmateArtifacts({
@ -198,63 +235,7 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.manifestMarkdown).toContain("Artifact scope: `tasks/thread-main/turn-1`"); expect(result.manifestMarkdown).toContain("Artifact scope: `tasks/thread-main/turn-1`");
}); });
it("adopts current-run workspace root files into the task artifact scope", async () => { it("does not adopt workspace root files even with a current-run timestamp", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const sinceUnixMs = Date.now() - 1_000;
await fs.writeFile(path.join(root, "xhs_account_security.md"), "# Account security\n");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs,
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("task");
expect(result.artifactScope).toBe("tasks/thread-main/turn-1");
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["xhs_account_security.md"]);
expect(result.artifacts[0]).toMatchObject({
artifactScope: "tasks/thread-main/turn-1",
scopeKind: "task",
contentType: "text/markdown",
encoding: "base64",
content: Buffer.from("# Account security\n").toString("base64"),
});
expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "xhs_account_security.md"), "utf8")).toBe(
"# Account security\n",
);
});
it("creates the current task scope when adopting root files after bridge export", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const sinceUnixMs = Date.now() - 1_000;
await fs.mkdir(path.join(root, "reports"), { recursive: true });
await fs.writeFile(path.join(root, "reports", "final.md"), "final");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifactScope).toBe("tasks/thread-main/turn-1");
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/final.md"]);
expect(result.warnings).toEqual([]);
expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "reports", "final.md"), "utf8")).toBe(
"final",
);
});
it("adopts root Word documents into only the current 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-"));
const sinceUnixMs = Date.now() - 1_000; const sinceUnixMs = Date.now() - 1_000;
await fs.writeFile(path.join(root, "article.docx"), "docx-content"); await fs.writeFile(path.join(root, "article.docx"), "docx-content");
@ -270,18 +251,8 @@ describe("exportXWorkmateArtifacts", () => {
}); });
expect(result.artifactScope).toBe("tasks/draft-article/openclaw-run-1"); expect(result.artifactScope).toBe("tasks/draft-article/openclaw-run-1");
expect(result.artifacts).toHaveLength(1); expect(result.artifacts).toEqual([]);
expect(result.artifacts[0]).toMatchObject({ await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow();
relativePath: "article.docx",
artifactScope: "tasks/draft-article/openclaw-run-1",
scopeKind: "task",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
expect(result.artifacts[0]?.encoding).toBeUndefined();
expect(await fs.readFile(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"), "utf8")).toBe(
"docx-content",
);
await expect(fs.stat(path.join(root, "tasks", "draft-article", "turn-1", "article.docx"))).rejects.toThrow();
}); });
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 () => {

View File

@ -16,8 +16,6 @@ const SKIPPED_DIRS = new Set([
".dart_tool", ".dart_tool",
".next", ".next",
".turbo", ".turbo",
"build",
"dist",
"node_modules", "node_modules",
]); ]);
@ -166,23 +164,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
skipTaskScopeRoot: false,
warnSkippedSymlinks: true, warnSkippedSymlinks: true,
warnings, warnings,
}) })
: []; : [];
const adoptedCandidates = const candidates = scopedCandidates;
sinceUnixMs > 0
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
if (!scopePrepared && candidates.length === 0) { if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run"); warnings.push("artifact scope is not prepared for this task run");
} }
@ -403,58 +389,10 @@ export function formatArtifactManifestMarkdown(input: {
return lines.join("\n"); return lines.join("\n");
} }
async function adoptWorkspaceRootCandidatesIntoScope(input: {
workspaceRoot: string;
scopeRoot: string;
artifactScope: string;
sinceUnixMs: number;
existingRelativePaths: Set<string>;
warnings: string[];
}): Promise<Candidate[]> {
const rootCandidates = await collectCandidates({
scanRoot: input.workspaceRoot,
relativeRoot: input.workspaceRoot,
sinceUnixMs: input.sinceUnixMs,
skipTaskScopeRoot: true,
warnSkippedSymlinks: false,
warnings: input.warnings,
});
const adopted: Candidate[] = [];
for (const candidate of rootCandidates) {
if (input.existingRelativePaths.has(candidate.relativePath)) {
continue;
}
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
if (!isWithinRoot(input.scopeRoot, targetPath)) {
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
continue;
}
if (await fileExists(targetPath)) {
input.existingRelativePaths.add(candidate.relativePath);
continue;
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(candidate.absolutePath, targetPath);
const stat = await fs.stat(targetPath);
const realPath = await fs.realpath(targetPath);
adopted.push({
absolutePath: realPath,
relativePath: candidate.relativePath,
sizeBytes: stat.size,
mtimeMs: candidate.mtimeMs,
artifactScope: input.artifactScope,
scopeKind: "task",
});
input.existingRelativePaths.add(candidate.relativePath);
}
return adopted;
}
async function collectCandidates(input: { async function collectCandidates(input: {
scanRoot: string; scanRoot: string;
relativeRoot: string; relativeRoot: string;
sinceUnixMs: number; sinceUnixMs: number;
skipTaskScopeRoot: boolean;
warnSkippedSymlinks: boolean; warnSkippedSymlinks: boolean;
warnings: string[]; warnings: string[];
}): Promise<Candidate[]> { }): Promise<Candidate[]> {
@ -483,9 +421,6 @@ async function collectCandidates(input: {
continue; continue;
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
continue;
}
if (SKIPPED_DIRS.has(entry.name)) { if (SKIPPED_DIRS.has(entry.name)) {
continue; continue;
} }
@ -552,6 +487,7 @@ function safeScopeSegment(value: string): string {
.trim() .trim()
.replace(/[\\/]+/g, "_") .replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_") .replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "") .replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 96) || "scope"; .slice(0, 96) || "scope";
} }
@ -604,15 +540,6 @@ async function directoryExists(absolutePath: string): Promise<boolean> {
} }
} }
async function fileExists(absolutePath: string): Promise<boolean> {
try {
const stat = await fs.stat(absolutePath);
return stat.isFile();
} catch {
return false;
}
}
function safeArtifactRefRunScope(value: unknown): string { function safeArtifactRefRunScope(value: unknown): string {
try { try {
return safeTaskArtifactScope(value); return safeTaskArtifactScope(value);