Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions server/lib/agent-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,47 @@ describe('agent-workspace', () => {
});
});

it('prefers explicitly configured agent workspaces from openclaw.json', async () => {
const configDir = path.join(homeDir, '.openclaw');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(path.join(configDir, 'openclaw.json'), JSON.stringify({
agents: {
defaults: { workspace: '/managed/workspaces' },
list: [
{ id: 'research', workspace: '/vaults/research' },
],
},
}, null, 2));

const { resolveAgentWorkspace } = await loadModule();

expect(resolveAgentWorkspace('research')).toEqual({
agentId: 'research',
workspaceRoot: '/vaults/research',
memoryPath: '/vaults/research/MEMORY.md',
memoryDir: '/vaults/research/memory',
});
});

it('uses agents.defaults.workspace for new non-main agents when configured', async () => {
const configDir = path.join(homeDir, '.openclaw');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(path.join(configDir, 'openclaw.json'), JSON.stringify({
agents: {
defaults: { workspace: '/managed/workspaces' },
},
}, null, 2));

const { resolveAgentWorkspace } = await loadModule();

expect(resolveAgentWorkspace('research')).toEqual({
agentId: 'research',
workspaceRoot: '/managed/workspaces/research',
memoryPath: '/managed/workspaces/research/MEMORY.md',
memoryDir: '/managed/workspaces/research/memory',
});
});

it('returns workspaceRoot, memoryPath, and memoryDir together', async () => {
const { resolveAgentWorkspace } = await loadModule();

Expand Down
4 changes: 3 additions & 1 deletion server/lib/agent-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path';
import { config } from './config.js';
import { buildDefaultAgentWorkspacePath, getConfiguredAgentWorkspace } from './openclaw-config.js';

export interface AgentWorkspace {
agentId: string;
Expand Down Expand Up @@ -40,7 +41,8 @@ export function resolveAgentWorkspace(agentId?: string): AgentWorkspace {
};
}

const workspaceRoot = path.join(config.home, '.openclaw', `workspace-${normalizedAgentId}`);
const workspaceRoot = getConfiguredAgentWorkspace(normalizedAgentId)
|| buildDefaultAgentWorkspacePath(normalizedAgentId);
return {
agentId: normalizedAgentId,
workspaceRoot,
Expand Down
31 changes: 27 additions & 4 deletions server/lib/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { broadcast } from '../routes/events.js';
import { config } from './config.js';
import { resolveAgentWorkspace, type AgentWorkspace } from './agent-workspace.js';
import { isBinary, isExcluded } from './file-utils.js';
import { listConfiguredAgentWorkspaces, resolveOpenClawConfigPath } from './openclaw-config.js';
import { isWorkspaceLocal } from './workspace-detect.js';

let rootDirWatcher: FSWatcher | null = null;
Expand Down Expand Up @@ -73,6 +74,16 @@ function discoverWorkspaces(): AgentWorkspace[] {
const mainWorkspace = resolveAgentWorkspace('main');
workspaces.set(mainWorkspace.agentId, mainWorkspace);

for (const configured of listConfiguredAgentWorkspaces()) {
if (configured.agentId === 'main') continue;
workspaces.set(configured.agentId, {
agentId: configured.agentId,
workspaceRoot: configured.workspaceRoot,
memoryPath: path.join(configured.workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(configured.workspaceRoot, 'memory'),
});
}

const openclawDir = path.join(config.home, '.openclaw');
if (!existsSync(openclawDir)) {
return [...workspaces.values()];
Expand Down Expand Up @@ -190,13 +201,25 @@ function refreshWorkspaceWatchers(): void {

function startRootWorkspaceWatcher(): void {
const openclawDir = path.join(config.home, '.openclaw');
if (rootDirWatcher || !existsSync(openclawDir)) return;
if (rootDirWatcher) return;

try {
rootDirWatcher = watch(openclawDir, (_eventType, filename) => {
const configPath = resolveOpenClawConfigPath();
const watchTarget = existsSync(configPath)
? configPath
: openclawDir;

if (!existsSync(watchTarget)) return;

const watchingConfigFile = watchTarget === configPath;
const configBasename = path.basename(configPath);
rootDirWatcher = watch(watchTarget, (_eventType, filename) => {
const file = getWatchFilename(filename);
if (!file) return;
if (file === 'workspace' || file.startsWith(WORKSPACE_PREFIX)) {
if (
(watchingConfigFile && (!file || file === configBasename)) ||
file === 'workspace' ||
(file?.startsWith(WORKSPACE_PREFIX) ?? false)
) {
refreshWorkspaceWatchers();
}
});
Expand Down
117 changes: 117 additions & 0 deletions server/lib/openclaw-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import JSON5 from 'json5';
import { config } from './config.js';

interface OpenClawAgentEntry {
id?: unknown;
workspace?: unknown;
}

interface OpenClawConfigShape {
agents?: {
defaults?: {
workspace?: unknown;
};
list?: OpenClawAgentEntry[];
};
}

type CachedConfig = {
path: string;
mtimeMs: number;
parsed: OpenClawConfigShape | null;
};

let cachedConfig: CachedConfig | null = null;

function getHomeDir(): string {
return config.home || process.env.HOME || os.homedir();
}

export function resolveOpenClawConfigPath(): string {
return process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(getHomeDir(), '.openclaw', 'openclaw.json');
}

function expandAndResolvePath(rawPath: string, configPath: string): string {
const trimmed = rawPath.trim();
if (!trimmed) return trimmed;

const expanded = trimmed === '~'
? getHomeDir()
: trimmed.startsWith('~/')
? path.join(getHomeDir(), trimmed.slice(2))
: trimmed;

if (path.isAbsolute(expanded)) return expanded;
return path.resolve(path.dirname(configPath), expanded);
}

function loadOpenClawConfig(): { configPath: string; parsed: OpenClawConfigShape | null } {
const configPath = resolveOpenClawConfigPath();

try {
const stat = fs.statSync(configPath);
if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtimeMs === stat.mtimeMs) {
return { configPath, parsed: cachedConfig.parsed };
}

const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON5.parse(raw) as OpenClawConfigShape;
cachedConfig = { path: configPath, mtimeMs: stat.mtimeMs, parsed };
return { configPath, parsed };
} catch {
cachedConfig = { path: configPath, mtimeMs: -1, parsed: null };
return { configPath, parsed: null };
}
}

export function getConfiguredAgentWorkspace(agentId: string): string | null {
const { configPath, parsed } = loadOpenClawConfig();
const agents = parsed?.agents?.list;
if (!Array.isArray(agents)) return null;

const match = agents.find((entry) => typeof entry?.id === 'string' && entry.id === agentId);
if (!match || typeof match.workspace !== 'string' || !match.workspace.trim()) return null;

return expandAndResolvePath(match.workspace, configPath);
}

export function getDefaultAgentWorkspaceRoot(): string | null {
const { configPath, parsed } = loadOpenClawConfig();
const rawWorkspace = parsed?.agents?.defaults?.workspace;
if (typeof rawWorkspace !== 'string' || !rawWorkspace.trim()) return null;
return expandAndResolvePath(rawWorkspace, configPath);
}

export function buildDefaultAgentWorkspacePath(agentId: string): string {
const defaultWorkspaceRoot = getDefaultAgentWorkspaceRoot();
if (defaultWorkspaceRoot) {
return path.join(defaultWorkspaceRoot, agentId);
}
return path.join(getHomeDir(), '.openclaw', `workspace-${agentId}`);
}

export function listConfiguredAgentWorkspaces(): Array<{ agentId: string; workspaceRoot: string }> {
const { configPath, parsed } = loadOpenClawConfig();
const agents = parsed?.agents?.list;
if (!Array.isArray(agents)) return [];

const seen = new Set<string>();
const workspaces: Array<{ agentId: string; workspaceRoot: string }> = [];

for (const entry of agents) {
if (typeof entry?.id !== 'string' || !entry.id.trim()) continue;
if (typeof entry.workspace !== 'string' || !entry.workspace.trim()) continue;
if (seen.has(entry.id)) continue;
seen.add(entry.id);
workspaces.push({
agentId: entry.id,
workspaceRoot: expandAndResolvePath(entry.workspace, configPath),
});
}

return workspaces;
}

6 changes: 6 additions & 0 deletions server/routes/server-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ vi.mock('../lib/config.js', () => ({
config: { agentName: 'Jen' },
}));

vi.mock('../lib/openclaw-config.js', () => ({
getDefaultAgentWorkspaceRoot: () => '/mock/workspaces',
}));

vi.mock('../middleware/rate-limit.js', () => ({
rateLimitGeneral: vi.fn(async (_c: unknown, next: () => Promise<void>) => next()),
}));
Expand Down Expand Up @@ -85,6 +89,7 @@ describe('GET /api/server-info', () => {
expect(json.gatewayStartedAt).toBe(1700000012340);
expect(typeof json.serverTime).toBe('number');
expect(json.agentName).toBe('Jen');
expect(json.defaultAgentWorkspaceRoot).toBe('/mock/workspaces');
});

it('returns macOS gateway start time from ps output', async () => {
Expand Down Expand Up @@ -113,6 +118,7 @@ describe('GET /api/server-info', () => {

const json = (await res.json()) as Record<string, unknown>;
expect(json.gatewayStartedAt).toBe(new Date('Tue Mar 31 20:14:31 2026').getTime());
expect(json.defaultAgentWorkspaceRoot).toBe('/mock/workspaces');
expect(execCalls).toEqual([
{ file: 'ps', args: ['-axo', 'pid=,comm='] },
{ file: 'ps', args: ['-p', '72246', '-o', 'lstart='] },
Expand Down
2 changes: 2 additions & 0 deletions server/routes/server-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import { config } from '../lib/config.js';
import { getDefaultAgentWorkspaceRoot } from '../lib/openclaw-config.js';
import { rateLimitGeneral } from '../middleware/rate-limit.js';

const app = new Hono();
Expand Down Expand Up @@ -126,6 +127,7 @@ app.get('/api/server-info', rateLimitGeneral, async (c) => {
gatewayStartedAt: await getGatewayStartedAt(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
agentName: config.agentName,
defaultAgentWorkspaceRoot: getDefaultAgentWorkspaceRoot(),
});
});

Expand Down
37 changes: 37 additions & 0 deletions src/contexts/SessionContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,43 @@ describe('SessionContext', () => {
});
});

it('uses the server-provided default workspace root when spawning a root agent', async () => {
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;

if (url.includes('/api/server-info')) {
return Promise.resolve(jsonResponse({
agentName: 'Jen',
defaultAgentWorkspaceRoot: '/managed/workspaces',
}));
}
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;

function Spawn() {
const { spawnSession } = useSessionContext();
return <button data-testid="spawn-managed" onClick={() => spawnSession({
kind: 'root', agentName: 'Managed', task: 'hi', model: 'anthropic/claude-sonnet-4-5',
})} />;
}

render(<SessionProvider><Spawn /></SessionProvider>);
await waitFor(() => expect(rpcMock).toHaveBeenCalledWith('sessions.list', { limit: 1000 }));
screen.getByTestId('spawn-managed').click();
await waitFor(() => {
expect(rpcMock).toHaveBeenCalledWith('agents.create', expect.objectContaining({
name: 'Managed',
workspace: '/managed/workspaces/managed',
}));
});
});

it('uses the full gateway session list for sidebar refreshes so older agent chats stay visible', async () => {
render(
<SessionProvider>
Expand Down
15 changes: 12 additions & 3 deletions src/contexts/SessionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const [eventEntries, setEventEntries] = useState<EventEntry[]>([]);
const [agentStatus, setAgentStatus] = useState<Record<string, GranularAgentState>>({});
const [agentName, setAgentName] = useState('Agent');
const [defaultAgentWorkspaceRoot, setDefaultAgentWorkspaceRoot] = useState<string | null>(null);
const [unreadSessionKeys, setUnreadSessionKeys] = useState<Set<string>>(new Set());
const unreadSessionKeysRef = useRef(unreadSessionKeys);
const soundEnabledRef = useRef(soundEnabled);
Expand Down Expand Up @@ -152,10 +153,15 @@ export function SessionProvider({ children }: { children: ReactNode }) {
try {
const res = await fetch('/api/server-info', { signal: controller.signal });
if (!res.ok) return;
const data = await res.json();
const data = await res.json() as { agentName?: string; defaultAgentWorkspaceRoot?: string | null };
if (data.agentName) {
setAgentName(data.agentName);
}
setDefaultAgentWorkspaceRoot(
typeof data.defaultAgentWorkspaceRoot === 'string' && data.defaultAgentWorkspaceRoot.trim()
? data.defaultAgentWorkspaceRoot
: null,
);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
// silent fail - use default
Expand Down Expand Up @@ -747,8 +753,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
// Register agent in config (ignore if already registered)
const agentId = getRootAgentId(sessionKey);
const registrationName = getAgentRegistrationName(rootName, sessionKey);
const workspacePath = defaultAgentWorkspaceRoot
? `${defaultAgentWorkspaceRoot.replace(/\/+$/, '')}/${agentId}`
: `~/.openclaw/workspace-${agentId}`;
try {
await rpc('agents.create', { name: registrationName, workspace: `~/.openclaw/workspace-${agentId}` });
await rpc('agents.create', { name: registrationName, workspace: workspacePath });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('already exists')) throw err;
Expand Down Expand Up @@ -803,7 +812,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {

await refreshSessions();
setCurrentSession(data.sessionKey);
}, [listAuthoritativeSessions, rpc, refreshSessions, setCurrentSession]);
}, [defaultAgentWorkspaceRoot, listAuthoritativeSessions, rpc, refreshSessions, setCurrentSession]);

const renameSession = useCallback(async (sessionKey: string, label: string) => {
await rpc('sessions.patch', { key: sessionKey, label });
Expand Down