diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..e4a179ad80a 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,29 @@ 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; + } + return mw.map((p) => stripNullBytes(resolveUserPath(p))); +} + 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 +328,21 @@ 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. + const multiWs = resolveAgentMultipleWorkspaces(cfg, id); + if (multiWs) { + for (const ws of multiWs) { + const normalizedWs = normalizePathForComparison(ws); + if (isPathWithinRoot(normalizedWorkspacePath, normalizedWs)) { + matches.push({ id, workspaceDir: normalizedWs, order: index }); + break; + } + } + } } 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..f922bb7cb7c 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"; @@ -645,3 +646,87 @@ 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 }); + + // 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 base = path.basename(ws); + nameCount.set(base, (nameCount.get(base) ?? 0) + 1); + } + const nameUsed = new Map(); + for (const ws of workspacePaths) { + const base = path.basename(ws); + let linkName: string; + if ((nameCount.get(base) ?? 0) > 1) { + const parentName = path.basename(path.dirname(ws)); + const candidate = `${parentName}-${base}`; + const usedCount = nameUsed.get(candidate) ?? 0; + linkName = usedCount > 0 ? `${candidate}-${usedCount}` : candidate; + nameUsed.set(candidate, usedCount + 1); + } else { + linkName = base; + } + 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) => e.linkName)); + try { + const existing = await fs.readdir(compositeDir, { withFileTypes: true }); + for (const entry of existing) { + if (!entry.isSymbolicLink()) { + continue; + } + if (!desiredNames.has(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); + + // Warn on missing targets but still create the symlink. + try { + await fs.access(target); + } catch { + 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)) { + continue; + } + // Target changed — remove old symlink. + await fs.unlink(linkPath); + } catch { + // Symlink doesn't exist or isn't a symlink — remove whatever is there. + await fs.rm(linkPath, { force: true, recursive: false }).catch(() => {}); + } + + await fs.symlink(target, linkPath); + 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..aa46ff6b12a 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -1,12 +1,17 @@ import { resolveAgentDir, + resolveAgentMultipleWorkspaces, resolveAgentWorkspaceDir, resolveSessionAgentId, resolveAgentSkillsFilter, } from "../../agents/agent-scope.js"; import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, + ensureCompositeWorkspace, +} from "../../agents/workspace.js"; import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { applyLinkUnderstanding } from "../../link-understanding/apply.js"; @@ -103,6 +108,13 @@ export async function getReplyFromConfig( } const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; + + // multipleWorkspaces: create composite symlink directory before bootstrapping. + const multiWsPaths = resolveAgentMultipleWorkspaces(cfg, agentId); + if (multiWsPaths) { + await ensureCompositeWorkspace({ compositeDir: workspaceDirRaw, workspacePaths: multiWsPaths }); + } + const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, 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 10cef396275..9dfb3caa783 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -770,6 +770,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(),