diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..b8fc856a3f6 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -28,6 +28,7 @@ type AgentEntry = NonNullable["list"]>[num type ResolvedAgentConfig = { name?: string; workspace?: string; + multipleWorkspaces?: string[]; agentDir?: string; model?: AgentEntry["model"]; skills?: AgentEntry["skills"]; @@ -127,6 +128,9 @@ export function resolveAgentConfig( return { name: typeof entry.name === "string" ? entry.name : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, + multipleWorkspaces: Array.isArray(entry.multipleWorkspaces) + ? entry.multipleWorkspaces + : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: typeof entry.model === "string" || (entry.model && typeof entry.model === "object") @@ -253,8 +257,32 @@ export function resolveEffectiveModelFallbacks(params: { return agentFallbacksOverride ?? defaultFallbacks; } +export function resolveAgentMultipleWorkspaces( + cfg: OpenClawConfig, + agentId: string, +): string[] | undefined { + const id = normalizeAgentId(agentId); + const agentConfig = resolveAgentConfig(cfg, id); + const mw = agentConfig?.multipleWorkspaces; + if (!Array.isArray(mw) || mw.length === 0) { + return undefined; + } + const resolved = mw + .filter((p) => typeof p === "string" && p.trim().length > 0) + .map((p) => stripNullBytes(resolveUserPath(p))); + return resolved.length > 0 ? resolved : undefined; +} + export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); + + // multipleWorkspaces takes precedence: return a composite workspace dir. + const multiWs = resolveAgentMultipleWorkspaces(cfg, id); + if (multiWs) { + const stateDir = resolveStateDir(process.env); + return stripNullBytes(path.join(stateDir, `workspace-composite-${id}`)); + } + const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) { return stripNullBytes(resolveUserPath(configured)); @@ -303,10 +331,26 @@ export function resolveAgentIdsByWorkspacePath( for (let index = 0; index < ids.length; index += 1) { const id = ids[index]; const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id)); - if (!isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) { + if (isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) { + matches.push({ id, workspaceDir, order: index }); continue; } - matches.push({ id, workspaceDir, order: index }); + // Also match individual multipleWorkspaces entries — pick the most specific (longest path). + const multiWs = resolveAgentMultipleWorkspaces(cfg, id); + if (multiWs) { + let bestMatch: string | null = null; + for (const ws of multiWs) { + const normalizedWs = normalizePathForComparison(ws); + if (isPathWithinRoot(normalizedWorkspacePath, normalizedWs)) { + if (!bestMatch || normalizedWs.length > bestMatch.length) { + bestMatch = normalizedWs; + } + } + } + if (bestMatch) { + matches.push({ id, workspaceDir: bestMatch, order: index }); + } + } } matches.sort((left, right) => { diff --git a/src/agents/workspace-dirs.ts b/src/agents/workspace-dirs.ts index 62adbddd471..4ad03f8bdf1 100644 --- a/src/agents/workspace-dirs.ts +++ b/src/agents/workspace-dirs.ts @@ -1,5 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { + resolveAgentMultipleWorkspaces, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "./agent-scope.js"; export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] { const dirs = new Set(); @@ -8,6 +12,13 @@ export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] { for (const entry of list) { if (entry && typeof entry === "object" && typeof entry.id === "string") { dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + // Also include individual multipleWorkspaces entries. + const multiWs = resolveAgentMultipleWorkspaces(cfg, entry.id); + if (multiWs) { + for (const ws of multiWs) { + dirs.add(ws); + } + } } } } diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 09159b4b072..5c535bc20e0 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { openBoundaryFile } from "../infra/boundary-file-read.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; @@ -327,6 +328,7 @@ async function ensureGitRepo(dir: string, isBrandNewWorkspace: boolean) { export async function ensureAgentWorkspace(params?: { dir?: string; ensureBootstrapFiles?: boolean; + multipleWorkspaces?: string[]; }): Promise<{ dir: string; agentsPath?: string; @@ -341,6 +343,16 @@ export async function ensureAgentWorkspace(params?: { const dir = resolveUserPath(rawDir); await fs.mkdir(dir, { recursive: true }); + // If multipleWorkspaces is set, populate the composite directory with symlinks + // before any bootstrap file creation. This ensures all execution paths (reply, + // CLI, heartbeat, etc.) see the same composite workspace. + if (params?.multipleWorkspaces && params.multipleWorkspaces.length > 0) { + await ensureCompositeWorkspace({ + compositeDir: dir, + workspacePaths: params.multipleWorkspaces, + }); + } + if (!params?.ensureBootstrapFiles) { return { dir }; } @@ -645,3 +657,152 @@ export async function loadExtraBootstrapFilesWithDiagnostics( } return { files, diagnostics }; } + +const compositeLog = createSubsystemLogger("composite-workspace"); + +/** + * Ensure a composite workspace directory exists with symlinks to each workspace path. + * Stale symlinks (pointing to paths not in the current set) are removed. + * Basename collisions are resolved by prepending the parent directory name. + */ +export async function ensureCompositeWorkspace(params: { + compositeDir: string; + workspacePaths: string[]; +}): Promise { + const { compositeDir, workspacePaths } = params; + await fs.mkdir(compositeDir, { recursive: true }); + + // On case-insensitive filesystems (macOS, Windows) names like "Repo" and "repo" + // resolve to the same on-disk entry, so we normalize all collision checks to lowercase. + const isCaseInsensitive = process.platform === "darwin" || process.platform === "win32"; + const normalizeKey = (name: string) => (isCaseInsensitive ? name.toLowerCase() : name); + + // Build unique link names from basenames, deduping collisions. + const linkEntries: Array<{ linkName: string; target: string }> = []; + const nameCount = new Map(); + for (const ws of workspacePaths) { + const key = normalizeKey(path.basename(ws)); + nameCount.set(key, (nameCount.get(key) ?? 0) + 1); + } + + // Internal workspace names (bootstrap files, memory dir) that must never be used as symlink names. + const internalNamesRaw = [ + DEFAULT_AGENTS_FILENAME, + DEFAULT_SOUL_FILENAME, + DEFAULT_TOOLS_FILENAME, + DEFAULT_IDENTITY_FILENAME, + DEFAULT_USER_FILENAME, + DEFAULT_HEARTBEAT_FILENAME, + DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_MEMORY_FILENAME, + DEFAULT_MEMORY_ALT_FILENAME, + "memory", + WORKSPACE_STATE_DIRNAME, + ]; + const internalNames = new Set(internalNamesRaw.map(normalizeKey)); + // Track all taken names so collision resolution avoids them. Seed with internal names + // and non-collision basenames (which keep their short names). + const takenNames = new Set(internalNames); + for (const ws of workspacePaths) { + const key = normalizeKey(path.basename(ws)); + if ((nameCount.get(key) ?? 0) === 1 && !internalNames.has(key)) { + takenNames.add(key); + } + } + + const nameUsed = new Map(); + for (const ws of workspacePaths) { + const base = path.basename(ws); + const baseKey = normalizeKey(base); + let linkName: string; + // Dedup when basename collides with another workspace or with an internal name. + const needsDedup = (nameCount.get(baseKey) ?? 0) > 1 || internalNames.has(baseKey); + if (needsDedup) { + const parentName = path.basename(path.dirname(ws)); + const candidate = `${parentName}-${base}`; + const candidateKey = normalizeKey(candidate); + let suffix = nameUsed.get(candidateKey) ?? 0; + linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate; + // Ensure the resolved name doesn't collide with any taken name. + while (takenNames.has(normalizeKey(linkName))) { + suffix += 1; + linkName = `${candidate}-${suffix}`; + } + nameUsed.set(candidateKey, suffix + 1); + } else { + linkName = base; + } + takenNames.add(normalizeKey(linkName)); + linkEntries.push({ linkName, target: ws }); + } + + // Remove stale symlinks in composite dir that are not in the current set. + const desiredNames = new Set( + linkEntries.map((e) => (isCaseInsensitive ? e.linkName.toLowerCase() : e.linkName)), + ); + try { + const existing = await fs.readdir(compositeDir, { withFileTypes: true }); + for (const entry of existing) { + if (!entry.isSymbolicLink()) { + continue; + } + if (!desiredNames.has(isCaseInsensitive ? entry.name.toLowerCase() : entry.name)) { + const stalePath = path.join(compositeDir, entry.name); + compositeLog.info(`Removing stale symlink: ${stalePath}`); + await fs.unlink(stalePath).catch(() => {}); + } + } + } catch { + // Directory may not exist yet or be unreadable. + } + + // Create or update symlinks. + for (const { linkName, target } of linkEntries) { + const linkPath = path.join(compositeDir, linkName); + const targetExists = await fs + .access(target) + .then(() => true) + .catch(() => false); + const symlinkType = process.platform === "win32" ? "dir" : undefined; + + // Warn on missing targets but still create the symlink. + if (!targetExists) { + compositeLog.warn(`Workspace target does not exist: ${target}`); + } + + // Check if symlink already points to the correct target. + try { + const existingTarget = await fs.readlink(linkPath); + if (path.resolve(existingTarget) === path.resolve(target)) { + // On Windows, a missing target created without an explicit "dir" type can leave + // behind a file-typed symlink that resolves to the right target string but is not + // usable as a directory once the target appears. When the target exists, verify the + // resolved link is traversable as a directory before keeping it. + if (!(process.platform === "win32" && targetExists)) { + continue; + } + try { + const stats = await fs.stat(linkPath); + if (stats.isDirectory()) { + continue; + } + } catch { + // Recreate the symlink with the explicit directory type below. + } + } + // Target changed or existing Windows link is unusable — remove old symlink. + await fs.unlink(linkPath); + } catch { + // Symlink doesn't exist or isn't a symlink — try to remove whatever is there. + try { + await fs.rm(linkPath, { force: true, recursive: false }); + } catch (rmErr) { + compositeLog.warn(`Cannot replace non-symlink at ${linkPath}: ${String(rmErr)}`); + continue; + } + } + + await fs.symlink(target, linkPath, symlinkType); + compositeLog.info(`Symlinked ${linkName} -> ${target}`); + } +} diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 9cee46cc2c9..7a73f8b32f9 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -1,5 +1,6 @@ import { resolveAgentDir, + resolveAgentMultipleWorkspaces, resolveAgentWorkspaceDir, resolveSessionAgentId, resolveAgentSkillsFilter, @@ -106,6 +107,7 @@ export async function getReplyFromConfig( const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + multipleWorkspaces: resolveAgentMultipleWorkspaces(cfg, agentId), }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..754ec24cb32 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -63,6 +63,7 @@ export type AgentConfig = { default?: boolean; name?: string; workspace?: string; + multipleWorkspaces?: string[]; agentDir?: string; model?: AgentModelConfig; /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..bbc20ad6ce4 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -765,6 +765,7 @@ export const AgentEntrySchema = z default: z.boolean().optional(), name: z.string().optional(), workspace: z.string().optional(), + multipleWorkspaces: z.array(z.string()).min(1).optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), skills: z.array(z.string()).optional(),