From 0614937b981ad6b9939629abaea974582a41af84 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Mon, 16 Mar 2026 21:33:33 +0800 Subject: [PATCH 1/6] feat(agents): support multiple workspaces via composite symlink directory Allow agents to cover multiple physical workspaces by adding a multipleWorkspaces field (string[]). At runtime a composite directory is created with symlinks to each workspace, serving as the agent unified workspace root. Bootstrap files live in the composite root while each symlinked project retains its own .ai_context/. Design philosophy from AGENT_COMPOSITION_AND_GRANULARITY.md: separate the brain (agent identity) from the hands (execution directories) so one agent persona can span multiple codebases. Co-Authored-By: Claude Opus 4.6 --- src/agents/agent-scope.ts | 40 +++++++++++- src/agents/workspace-dirs.ts | 13 +++- src/agents/workspace.ts | 85 ++++++++++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 14 ++++- src/config/types.agents.ts | 1 + src/config/zod-schema.agent-runtime.ts | 1 + 6 files changed, 150 insertions(+), 4 deletions(-) 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(), From 4681cb36cc80d9b89f4b551a64bb92537f5a5c49 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Tue, 17 Mar 2026 12:17:04 +0800 Subject: [PATCH 2/6] fix: address code review feedback on ensureCompositeWorkspace - Fix dedup collision: check generated names against all reserved names to avoid EEXIST when parentName-base matches an existing non-collision basename - Fix silent fs.rm failure: log warning and skip instead of letting EEXIST propagate uncaught when a directory occupies a link path - Filter blank multipleWorkspaces entries before resolving paths - Pick most specific (longest) multipleWorkspaces match in reverse lookup Co-Authored-By: Claude Opus 4.6 --- src/agents/agent-scope.ts | 16 ++++++++++++---- src/agents/workspace.ts | 31 ++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index e4a179ad80a..b8fc856a3f6 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -267,7 +267,10 @@ export function resolveAgentMultipleWorkspaces( if (!Array.isArray(mw) || mw.length === 0) { return undefined; } - return mw.map((p) => stripNullBytes(resolveUserPath(p))); + 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) { @@ -332,16 +335,21 @@ export function resolveAgentIdsByWorkspacePath( matches.push({ id, workspaceDir, order: index }); continue; } - // Also match individual multipleWorkspaces entries. + // 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)) { - matches.push({ id, workspaceDir: normalizedWs, order: index }); - break; + if (!bestMatch || normalizedWs.length > bestMatch.length) { + bestMatch = normalizedWs; + } } } + if (bestMatch) { + matches.push({ id, workspaceDir: bestMatch, order: index }); + } } } diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index f922bb7cb7c..7d7ef4f67e7 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -668,6 +668,16 @@ export async function ensureCompositeWorkspace(params: { const base = path.basename(ws); nameCount.set(base, (nameCount.get(base) ?? 0) + 1); } + + // First pass: collect all non-collision names so collision resolution can avoid them. + const reservedNames = new Set(); + for (const ws of workspacePaths) { + const base = path.basename(ws); + if ((nameCount.get(base) ?? 0) === 1) { + reservedNames.add(base); + } + } + const nameUsed = new Map(); for (const ws of workspacePaths) { const base = path.basename(ws); @@ -675,9 +685,15 @@ export async function ensureCompositeWorkspace(params: { 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); + let suffix = nameUsed.get(candidate) ?? 0; + linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate; + // Ensure the resolved name doesn't collide with a non-collision entry or existing entry. + while (reservedNames.has(linkName)) { + suffix += 1; + linkName = `${candidate}-${suffix}`; + } + nameUsed.set(candidate, suffix + 1); + reservedNames.add(linkName); } else { linkName = base; } @@ -722,8 +738,13 @@ export async function ensureCompositeWorkspace(params: { // 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(() => {}); + // 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); From e580331b925fd0e7a3ba557ffb8af6ecd0640250 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Tue, 17 Mar 2026 12:46:28 +0800 Subject: [PATCH 3/6] fix: integrate composite workspace into ensureAgentWorkspace and reserve internal names - Move ensureCompositeWorkspace call into ensureAgentWorkspace so all execution paths (CLI, heartbeat, etc.) benefit, not just getReplyFromConfig - Reserve bootstrap filenames, memory dir, and .openclaw as internal names to prevent symlink collisions with workspace basenames - Single-basename workspaces that collide with reserved names get renamed using parentName-base pattern Co-Authored-By: Claude Opus 4.6 --- src/agents/workspace.ts | 35 ++++++++++++++++++++++++++----- src/auto-reply/reply/get-reply.ts | 14 ++----------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 7d7ef4f67e7..321f0a08243 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -328,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; @@ -342,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 }; } @@ -669,8 +680,21 @@ export async function ensureCompositeWorkspace(params: { nameCount.set(base, (nameCount.get(base) ?? 0) + 1); } - // First pass: collect all non-collision names so collision resolution can avoid them. - const reservedNames = new Set(); + // Reserve internal workspace names (bootstrap files, memory dir) to prevent symlink collisions. + const reservedNames = new Set([ + 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, + ]); + // Also collect all non-collision basenames so collision resolution can avoid them. for (const ws of workspacePaths) { const base = path.basename(ws); if ((nameCount.get(base) ?? 0) === 1) { @@ -682,21 +706,22 @@ export async function ensureCompositeWorkspace(params: { for (const ws of workspacePaths) { const base = path.basename(ws); let linkName: string; - if ((nameCount.get(base) ?? 0) > 1) { + const needsDedup = (nameCount.get(base) ?? 0) > 1 || reservedNames.has(base); + if (needsDedup) { const parentName = path.basename(path.dirname(ws)); const candidate = `${parentName}-${base}`; let suffix = nameUsed.get(candidate) ?? 0; linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate; - // Ensure the resolved name doesn't collide with a non-collision entry or existing entry. + // Ensure the resolved name doesn't collide with any reserved or already-used name. while (reservedNames.has(linkName)) { suffix += 1; linkName = `${candidate}-${suffix}`; } nameUsed.set(candidate, suffix + 1); - reservedNames.add(linkName); } else { linkName = base; } + reservedNames.add(linkName); linkEntries.push({ linkName, target: ws }); } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index aa46ff6b12a..7a73f8b32f9 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -7,11 +7,7 @@ import { } from "../../agents/agent-scope.js"; import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { - DEFAULT_AGENT_WORKSPACE_DIR, - ensureAgentWorkspace, - ensureCompositeWorkspace, -} from "../../agents/workspace.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } 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"; @@ -108,16 +104,10 @@ 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, + multipleWorkspaces: resolveAgentMultipleWorkspaces(cfg, agentId), }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); From e906f9d6b9a56476a52f449fe5063abebcbf991b Mon Sep 17 00:00:00 2001 From: voicewitness Date: Tue, 17 Mar 2026 15:57:24 +0800 Subject: [PATCH 4/6] fix(workspace): preserve basename links for non-colliding workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate internal reserved names (bootstrap files, memory dir) from taken-name tracking. Non-collision basenames are no longer forced through the dedup path — only basenames that collide with another workspace or with an internal name get parent-prefixed. This keeps symlink names stable and predictable (e.g. /repos/api → api, not parent-api). Co-Authored-By: Claude Opus 4.6 --- src/agents/workspace.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 321f0a08243..6634707d523 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -680,8 +680,8 @@ export async function ensureCompositeWorkspace(params: { nameCount.set(base, (nameCount.get(base) ?? 0) + 1); } - // Reserve internal workspace names (bootstrap files, memory dir) to prevent symlink collisions. - const reservedNames = new Set([ + // Internal workspace names (bootstrap files, memory dir) that must never be used as symlink names. + const internalNames = new Set([ DEFAULT_AGENTS_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, @@ -694,11 +694,13 @@ export async function ensureCompositeWorkspace(params: { "memory", WORKSPACE_STATE_DIRNAME, ]); - // Also collect all non-collision basenames so collision resolution can avoid them. + // 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 base = path.basename(ws); - if ((nameCount.get(base) ?? 0) === 1) { - reservedNames.add(base); + if ((nameCount.get(base) ?? 0) === 1 && !internalNames.has(base)) { + takenNames.add(base); } } @@ -706,14 +708,15 @@ export async function ensureCompositeWorkspace(params: { for (const ws of workspacePaths) { const base = path.basename(ws); let linkName: string; - const needsDedup = (nameCount.get(base) ?? 0) > 1 || reservedNames.has(base); + // Dedup when basename collides with another workspace or with an internal name. + const needsDedup = (nameCount.get(base) ?? 0) > 1 || internalNames.has(base); if (needsDedup) { const parentName = path.basename(path.dirname(ws)); const candidate = `${parentName}-${base}`; let suffix = nameUsed.get(candidate) ?? 0; linkName = suffix > 0 ? `${candidate}-${suffix}` : candidate; - // Ensure the resolved name doesn't collide with any reserved or already-used name. - while (reservedNames.has(linkName)) { + // Ensure the resolved name doesn't collide with any taken name. + while (takenNames.has(linkName)) { suffix += 1; linkName = `${candidate}-${suffix}`; } @@ -721,7 +724,7 @@ export async function ensureCompositeWorkspace(params: { } else { linkName = base; } - reservedNames.add(linkName); + takenNames.add(linkName); linkEntries.push({ linkName, target: ws }); } From 0ab64a5f99d078a3a918d47dcbc697d63bbe5170 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Tue, 17 Mar 2026 16:08:30 +0800 Subject: [PATCH 5/6] fix(workspace): normalize composite link names case-insensitively On case-insensitive filesystems (macOS, Windows), names like "Repo" and "repo" resolve to the same on-disk entry. Normalize all collision detection keys to lowercase on these platforms to prevent silent symlink overwrites and internal name guard bypasses. Co-Authored-By: Claude Opus 4.6 --- src/agents/workspace.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 6634707d523..c9779dee87e 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -672,16 +672,21 @@ export async function ensureCompositeWorkspace(params: { 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 base = path.basename(ws); - nameCount.set(base, (nameCount.get(base) ?? 0) + 1); + 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 internalNames = new Set([ + const internalNamesRaw = [ DEFAULT_AGENTS_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, @@ -693,50 +698,55 @@ export async function ensureCompositeWorkspace(params: { 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 base = path.basename(ws); - if ((nameCount.get(base) ?? 0) === 1 && !internalNames.has(base)) { - takenNames.add(base); + 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(base) ?? 0) > 1 || internalNames.has(base); + const needsDedup = (nameCount.get(baseKey) ?? 0) > 1 || internalNames.has(baseKey); if (needsDedup) { const parentName = path.basename(path.dirname(ws)); const candidate = `${parentName}-${base}`; - let suffix = nameUsed.get(candidate) ?? 0; + 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(linkName)) { + while (takenNames.has(normalizeKey(linkName))) { suffix += 1; linkName = `${candidate}-${suffix}`; } - nameUsed.set(candidate, suffix + 1); + nameUsed.set(candidateKey, suffix + 1); } else { linkName = base; } - takenNames.add(linkName); + 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) => e.linkName)); + 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(entry.name)) { + 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(() => {}); From 76f53095d2401418bc475cc9dbd067c622644d53 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Tue, 17 Mar 2026 20:43:25 +0800 Subject: [PATCH 6/6] fix(workspace): use directory symlinks for missing Windows targets When a configured workspace path is temporarily missing on Windows, create the composite link with explicit dir type and rebuild existing links once the target appears if the current link is not traversable as a directory. This prevents file-typed symlinks from becoming stuck. Co-Authored-By: Claude Opus 4.6 --- src/agents/workspace.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index c9779dee87e..5c535bc20e0 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -759,11 +759,14 @@ export async function ensureCompositeWorkspace(params: { // 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. - try { - await fs.access(target); - } catch { + if (!targetExists) { compositeLog.warn(`Workspace target does not exist: ${target}`); } @@ -771,9 +774,23 @@ export async function ensureCompositeWorkspace(params: { try { const existingTarget = await fs.readlink(linkPath); if (path.resolve(existingTarget) === path.resolve(target)) { - continue; + // 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 — remove old symlink. + // 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. @@ -785,7 +802,7 @@ export async function ensureCompositeWorkspace(params: { } } - await fs.symlink(target, linkPath); + await fs.symlink(target, linkPath, symlinkType); compositeLog.info(`Symlinked ${linkName} -> ${target}`); } }