feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR (#3135)
* feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR
Projectless-chat worktrees were hard-pinned to
<home>/.config/openchamber/chats: the UI joined the path client-side,
workspace checks allowed only the config root, and identification matched
the literal path segment. When the OpenCode server runs as a separate
user (UID-separated setups), that root is unreachable — every chat
session answered HTTP 500 (EACCES on the session directory).
The server now owns the chats root. OPENCHAMBER_CHATS_DIR relocates it
(default unchanged: <config root>/chats); /api/fs/home answers
{ home, chatsRoot }; fs workspace checks accept the managed chats root
next to the config root; the client resolves the root from the server
(per-runtime cached, warmed at bootstrap so sync classification sees it)
and falls back to the home join for older servers.
Refs #3130
* chore: trim added comments to local precedent
* fix: forward managedChatsRoot through feature-routes-runtime to registerFsRoutes
* fix(chats): await the root warm-up and keep the legacy chats root owned
Review feedback on #3135:
- bootstrapGlobal now awaits warmChatsRootDirectory, so synchronous
session classification never sees an empty root cache (relocated
sessions were grouped as project sessions when the session list
outran /api/fs/home).
- managedProjectRoots keeps the legacy <config root>/chats entry next to
OPENCHAMBER_CHATS_DIR, so memory ownership of existing chats survives
relocation.
* fix(chats): distinguish chats-root fetch failure from older servers
* fix(sync): rehydrate managed chat sessions after the chats root warms
* fix(fs): pass managed roots through the symlink and git-dirs path checks after the main merge
* docs: drop changelog edits; changelog is the maintainer's release-time work
* fix(chats): keep legacy chat directories deletable while the root is relocated
* fix(chats): resolve roots before cleanup and initial session loads
* test(chats): type runtime spies against actual SDK contracts
---------
Signed-off-by: Steffen Mächtel <info@steffen-maechtel.de>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1d6b15bc04
commit
3df97908fe
@@ -1,55 +1,70 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { createChatDirectory, deleteChatDirectory, ensureChatsRootDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from './chatDirectories';
|
||||
|
||||
const createdDirectories: string[] = [];
|
||||
const createDirectoryOptions: Array<{ allowOutsideWorkspace?: boolean } | undefined> = [];
|
||||
const deletedDirectories: string[] = [];
|
||||
let runtime = 0;
|
||||
const nextRuntime = () => switchRuntimeEndpoint({ apiBaseUrl: 'https://chats.test', runtimeKey: `chats-${++runtime}` });
|
||||
let home = spyOn(opencodeClient, 'getFilesystemHomeInfo');
|
||||
let mkdir = spyOn(opencodeClient, 'createDirectory');
|
||||
let request = spyOn(globalThis, 'fetch');
|
||||
const deleteRequests = () => request.mock.calls.filter(([input]) => String(input).includes('/fs/delete'));
|
||||
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getFilesystemHome: mock(async () => '/Users/tester'),
|
||||
createDirectory: mock(async (path: string, options?: { allowOutsideWorkspace?: boolean }) => {
|
||||
createdDirectories.push(path);
|
||||
createDirectoryOptions.push(options);
|
||||
return { success: true, path };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
beforeEach(() => {
|
||||
nextRuntime();
|
||||
home = spyOn(opencodeClient, 'getFilesystemHomeInfo').mockResolvedValue({ home: '/home/user', chatsRoot: '/srv/chats' });
|
||||
mkdir = spyOn(opencodeClient, 'createDirectory').mockResolvedValue({ success: true, path: '/unused' });
|
||||
request = spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
|
||||
});
|
||||
afterEach(() => { home.mockRestore(); mkdir.mockRestore(); request.mockRestore(); });
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async (_path: string, init?: RequestInit) => {
|
||||
deletedDirectories.push(JSON.parse(String(init?.body)).path);
|
||||
return new Response(null, { status: 200 });
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } = await import('./chatDirectories');
|
||||
|
||||
describe('chat directories', () => {
|
||||
beforeEach(() => {
|
||||
createdDirectories.length = 0;
|
||||
createDirectoryOptions.length = 0;
|
||||
deletedDirectories.length = 0;
|
||||
describe('server-owned chat directories', () => {
|
||||
test('creates beneath the relocated root and uses the legacy root only for an older response', async () => {
|
||||
expect((await createChatDirectory(new Date(2026, 8, 5))).startsWith('/srv/chats/2026-09-05/session-')).toBe(true);
|
||||
nextRuntime();
|
||||
home.mockResolvedValue({ home: '/home/user' });
|
||||
expect((await createChatDirectory(new Date(2026, 8, 5))).startsWith('/home/user/.config/openchamber/chats/2026-09-05/session-')).toBe(true);
|
||||
});
|
||||
|
||||
test('creates one isolated directory beneath the dated chats root', async () => {
|
||||
const directory = await createChatDirectory(new Date(2026, 7, 21, 12));
|
||||
expect(createdDirectories[0]).toBe(directory);
|
||||
expect(directory.startsWith('/Users/tester/.config/openchamber/chats/2026-08-21/session-')).toBe(true);
|
||||
expect(createdDirectories).toEqual([directory]);
|
||||
expect(createDirectoryOptions).toEqual([undefined]);
|
||||
test('classifies only exact configured and actual legacy roots after warming', async () => {
|
||||
expect(isChatDirectoryPath('/work/backup/.config/openchamber/chats/project')).toBe(false);
|
||||
await ensureChatsRootDirectory();
|
||||
expect(isChatDirectoryPath('/srv/chats/day/session-a')).toBe(true);
|
||||
expect(isChatDirectoryPath('/home/user/.config/openchamber/chats/day/session-a')).toBe(true);
|
||||
expect(isChatDirectoryPath('/work/backup/.config/openchamber/chats/project')).toBe(false);
|
||||
expect(isChatDirectoryPath('/srv/chats-other/session-a')).toBe(false);
|
||||
expect(getChatsRootFromDirectory('/srv/chats/day/session-a')).toBe('/srv/chats');
|
||||
expect(isChatDirectoryForHome('/other/.config/openchamber/chats/session-a', '/home/user')).toBe(false);
|
||||
});
|
||||
|
||||
test('recognizes only descendants of the managed chats root', () => {
|
||||
expect(isChatDirectoryForHome('/Users/tester/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
|
||||
expect(isChatDirectoryForHome('/Users/tester/project', '/Users/tester')).toBe(false);
|
||||
expect(isChatDirectoryForHome('/remote/home/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
|
||||
expect(isChatDirectoryPath('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe(true);
|
||||
expect(getChatsRootFromDirectory('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe('/remote/home/.config/openchamber/chats');
|
||||
test('deletes real descendants but never shared roots, lookalikes, or traversal paths', async () => {
|
||||
for (const path of ['/srv/chats', '/home/user/.config/openchamber/chats', '/work/backup/.config/openchamber/chats/project', '/srv/chats/../project']) {
|
||||
await deleteChatDirectory(path);
|
||||
}
|
||||
expect(deleteRequests()).toHaveLength(0);
|
||||
await deleteChatDirectory('/srv/chats/day/session-a');
|
||||
await deleteChatDirectory('/home/user/.config/openchamber/chats/day/session-b');
|
||||
expect(deleteRequests()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('deletes managed chat directories but leaves project directories alone', async () => {
|
||||
await deleteChatDirectory('/Users/tester/.config/openchamber/chats/2026-08-21/session-a');
|
||||
await deleteChatDirectory('/Users/tester/project');
|
||||
expect(deletedDirectories).toEqual(['/Users/tester/.config/openchamber/chats/2026-08-21/session-a']);
|
||||
test('failed root lookup never creates or deletes, and the next attempt retries', async () => {
|
||||
home.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(deleteChatDirectory('/srv/chats/day/session-a')).rejects.toThrow('offline');
|
||||
expect(deleteRequests()).toHaveLength(0);
|
||||
home.mockRejectedValueOnce(new Error('offline'));
|
||||
await warmChatsRootDirectory();
|
||||
await createChatDirectory();
|
||||
expect(mkdir.mock.calls).toHaveLength(1);
|
||||
expect(home.mock.calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('runtime switch during root lookup cannot delete on the destination runtime', async () => {
|
||||
let resolve!: (value: { home: string; chatsRoot: string }) => void;
|
||||
home.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
|
||||
const deletion = deleteChatDirectory('/srv/chats/day/session-a');
|
||||
nextRuntime();
|
||||
resolve({ home: '/home/user', chatsRoot: '/srv/chats' });
|
||||
await expect(deletion).rejects.toThrow('Runtime changed');
|
||||
expect(deleteRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,48 +4,61 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
export const CHAT_DRAFT_PROJECT_ID = 'openchamber:chats';
|
||||
const MANAGED_CHATS_PATH_SEGMENT = '/.config/openchamber/chats/';
|
||||
const chatsRootByRuntime = new Map<string, Promise<string>>();
|
||||
type ChatRoots = { configured: string; legacy: string };
|
||||
const chatsRootByRuntime = new Map<string, Promise<ChatRoots>>();
|
||||
const chatsRootCacheByRuntime = new Map<string, ChatRoots>();
|
||||
|
||||
const joinPath = (base: string, ...parts: string[]): string => {
|
||||
const separator = base.includes('\\') ? '\\' : '/';
|
||||
return [base.replace(/[\\/]+$/, ''), ...parts].join(separator);
|
||||
};
|
||||
const joinPath = (base: string, ...parts: string[]): string =>
|
||||
[base.replace(/[\\/]+$/, ''), ...parts].join('/');
|
||||
|
||||
export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean {
|
||||
const normalized = normalizePath(directory ?? null);
|
||||
if (normalized?.includes(MANAGED_CHATS_PATH_SEGMENT)) return true;
|
||||
const normalizedHome = normalizePath(home ?? null);
|
||||
if (!normalized || !normalizedHome) return false;
|
||||
const root = normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats'));
|
||||
return Boolean(root && normalized.startsWith(`${root}/`));
|
||||
function legacyRootForHome(home: string | null | undefined): string | null {
|
||||
const normalized = normalizePath(home);
|
||||
return normalized ? joinPath(normalized, '.config', 'openchamber', 'chats') : null;
|
||||
}
|
||||
|
||||
export function isChatDirectoryPath(directory: string | null | undefined): boolean {
|
||||
return normalizePath(directory ?? null)?.includes(MANAGED_CHATS_PATH_SEGMENT) === true;
|
||||
function isWithinRoot(directory: string, root: string): boolean {
|
||||
return !directory.split('/').some((part) => part === '..' || part === '.')
|
||||
&& (directory === root || directory.startsWith(`${root}/`));
|
||||
}
|
||||
|
||||
function cachedRoots(): ChatRoots | undefined {
|
||||
return chatsRootCacheByRuntime.get(getRuntimeKey());
|
||||
}
|
||||
|
||||
export function getChatsRootFromDirectory(directory: string | null | undefined): string | null {
|
||||
const normalized = normalizePath(directory ?? null);
|
||||
const index = normalized?.indexOf(MANAGED_CHATS_PATH_SEGMENT) ?? -1;
|
||||
return normalized && index >= 0
|
||||
? normalized.slice(0, index + MANAGED_CHATS_PATH_SEGMENT.length - 1)
|
||||
: null;
|
||||
const normalized = normalizePath(directory);
|
||||
const roots = cachedRoots();
|
||||
if (!normalized || !roots) return null;
|
||||
if (isWithinRoot(normalized, roots.configured)) return roots.configured;
|
||||
return isWithinRoot(normalized, roots.legacy) ? roots.legacy : null;
|
||||
}
|
||||
|
||||
export function isChatDirectoryPath(directory: string | null | undefined): boolean {
|
||||
return getChatsRootFromDirectory(directory) !== null;
|
||||
}
|
||||
|
||||
export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean {
|
||||
if (isChatDirectoryPath(directory)) return true;
|
||||
const normalized = normalizePath(directory);
|
||||
const legacy = legacyRootForHome(home);
|
||||
return Boolean(normalized && legacy && isWithinRoot(normalized, legacy));
|
||||
}
|
||||
|
||||
export function getChatsRootForHome(home: string | null | undefined): string | null {
|
||||
const normalizedHome = normalizePath(home ?? null);
|
||||
return normalizedHome ? normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')) : null;
|
||||
return cachedRoots()?.configured ?? legacyRootForHome(home);
|
||||
}
|
||||
|
||||
async function getChatsRootDirectory(): Promise<string> {
|
||||
async function getChatRoots(): Promise<ChatRoots> {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const existing = chatsRootByRuntime.get(runtimeKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const pending = opencodeClient.getFilesystemHome().then((home) => {
|
||||
if (!home) throw new Error('Unable to resolve the home directory');
|
||||
return joinPath(home, '.config', 'openchamber', 'chats');
|
||||
const pending = opencodeClient.getFilesystemHomeInfo().then(({ home, chatsRoot }) => {
|
||||
const legacy = legacyRootForHome(home);
|
||||
const configured = normalizePath(chatsRoot) ?? legacy;
|
||||
if (!legacy || !configured) throw new Error('Unable to resolve chat directories');
|
||||
const roots = { configured, legacy };
|
||||
chatsRootCacheByRuntime.set(runtimeKey, roots);
|
||||
return roots;
|
||||
}).catch((error) => {
|
||||
chatsRootByRuntime.delete(runtimeKey);
|
||||
throw error;
|
||||
@@ -54,29 +67,35 @@ async function getChatsRootDirectory(): Promise<string> {
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function warmChatsRootDirectory(): void {
|
||||
void getChatsRootDirectory().catch(() => undefined);
|
||||
/** Required before a global snapshot can classify or persist managed chats. */
|
||||
export async function ensureChatsRootDirectory(): Promise<void> {
|
||||
await getChatRoots();
|
||||
}
|
||||
|
||||
export function warmChatsRootDirectory(): Promise<void> {
|
||||
return ensureChatsRootDirectory().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function createChatDirectory(now = new Date()): Promise<string> {
|
||||
const root = await getChatsRootDirectory();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const roots = await getChatRoots();
|
||||
if (getRuntimeKey() !== runtimeKey) throw new Error('Runtime changed while preparing chat directory');
|
||||
const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-');
|
||||
const dateDirectory = joinPath(root, date);
|
||||
const id = globalThis.crypto?.randomUUID?.() ?? `${now.getTime()}-${Math.random().toString(36).slice(2)}`;
|
||||
const directory = joinPath(dateDirectory, `session-${id}`);
|
||||
const directory = joinPath(roots.configured, date, `session-${id}`);
|
||||
await opencodeClient.createDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function isChatDirectory(directory: string | null | undefined): Promise<boolean> {
|
||||
const normalized = normalizePath(directory ?? null);
|
||||
if (!normalized) return false;
|
||||
const root = normalizePath(await getChatsRootDirectory());
|
||||
return Boolean(root && (normalized === root || normalized.startsWith(`${root}/`)));
|
||||
}
|
||||
|
||||
export async function deleteChatDirectory(directory: string): Promise<void> {
|
||||
if (!await isChatDirectory(directory)) return;
|
||||
const normalized = normalizePath(directory);
|
||||
if (!normalized) return;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const roots = await getChatRoots();
|
||||
if (getRuntimeKey() !== runtimeKey) throw new Error('Runtime changed while deleting chat directory');
|
||||
// A session may own a descendant, never either shared chats root itself.
|
||||
if (normalized === roots.configured || normalized === roots.legacy) return;
|
||||
if (!isWithinRoot(normalized, roots.configured) && !isWithinRoot(normalized, roots.legacy)) return;
|
||||
const response = await runtimeFetch('/api/fs/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -58,10 +58,19 @@ mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeKey: mock(() => runtimeKey),
|
||||
}));
|
||||
|
||||
const fsHomeResponses: Array<Response | Error> = [];
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async () => new Response(JSON.stringify([]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})),
|
||||
runtimeFetch: mock(async (input: string | URL | Request) => {
|
||||
if (typeof input === 'string' && input.includes('/fs/home')) {
|
||||
const next = fsHomeResponses.shift();
|
||||
if (next instanceof Error) throw next;
|
||||
if (next) return next;
|
||||
}
|
||||
return new Response(JSON.stringify([]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/startupTrace', () => ({
|
||||
@@ -75,6 +84,7 @@ beforeEach(() => {
|
||||
promptAsyncCalls.length = 0;
|
||||
promptAsyncResults.length = 0;
|
||||
pathGetResults.length = 0;
|
||||
fsHomeResponses.length = 0;
|
||||
});
|
||||
|
||||
describe('opencodeClient directory availability', () => {
|
||||
@@ -87,6 +97,45 @@ describe('opencodeClient directory availability', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencodeClient getFilesystemHomeInfo', () => {
|
||||
type HomePayload = { home?: string; chatsRoot?: string | number };
|
||||
const fsHomeResponse = (body: HomePayload) => new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('returns the server-provided chats root', async () => {
|
||||
fsHomeResponses.push(fsHomeResponse({ home: '/Users/tester', chatsRoot: '/srv/openchamber-chats' }));
|
||||
expect(await opencodeClient.getFilesystemHomeInfo()).toEqual({ home: '/Users/tester', chatsRoot: '/srv/openchamber-chats' });
|
||||
});
|
||||
|
||||
test('returns the home for an older server that answers without chatsRoot', async () => {
|
||||
fsHomeResponses.push(fsHomeResponse({ home: '/Users/tester' }));
|
||||
expect(await opencodeClient.getFilesystemHomeInfo()).toEqual({ home: '/Users/tester' });
|
||||
});
|
||||
|
||||
test('throws on a failed fetch', async () => {
|
||||
fsHomeResponses.push(new Error('transient network failure'));
|
||||
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow('transient network failure');
|
||||
});
|
||||
|
||||
test('throws on a non-ok response', async () => {
|
||||
fsHomeResponses.push(new Response('unavailable', { status: 503 }));
|
||||
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow('503');
|
||||
});
|
||||
|
||||
test('rejects missing home and relative roots rather than caching a fallback', async () => {
|
||||
fsHomeResponses.push(fsHomeResponse({}));
|
||||
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
|
||||
fsHomeResponses.push(fsHomeResponse({ home: '/home/user', chatsRoot: 'relative' }));
|
||||
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('throws on a malformed payload', async () => {
|
||||
fsHomeResponses.push(fsHomeResponse({ chatsRoot: 42 }));
|
||||
await expect(opencodeClient.getFilesystemHomeInfo()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencodeClient getConfig cache', () => {
|
||||
test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => {
|
||||
const first = opencodeClient.getConfig('/workspace/project');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
|
||||
import type { FilesAPI } from "../api/types";
|
||||
import { getDesktopHomeDirectory } from "../desktop";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
Session,
|
||||
Message,
|
||||
@@ -341,6 +342,11 @@ const getDesktopFilesApi = (): FilesAPI | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// /api/fs/home parsing boundary. Older servers answer without chatsRoot;
|
||||
// Only a valid home response may use the legacy chats-root fallback.
|
||||
const fsAbsolutePathSchema = z.string().trim().regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/);
|
||||
const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() });
|
||||
|
||||
class OpencodeService {
|
||||
private client: OpencodeClient;
|
||||
private baseUrl: string;
|
||||
@@ -1947,6 +1953,21 @@ class OpencodeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Both roots must describe the same server response, including on desktop.
|
||||
// Failure is distinct from an older server omitting chatsRoot.
|
||||
async getFilesystemHomeInfo(): Promise<z.infer<typeof fsHomeResponseSchema>> {
|
||||
const response = await runtimeFetch(`${this.baseUrl}/fs/home`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve the chats root (${response.status})`);
|
||||
}
|
||||
return fsHomeResponseSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
async setOpenCodeWorkingDirectory(directoryPath: string | null | undefined): Promise<DirectorySwitchResult | null> {
|
||||
if (!directoryPath || typeof directoryPath !== 'string' || !directoryPath.trim()) {
|
||||
console.warn('[OpencodeClient] setOpenCodeWorkingDirectory: invalid path', directoryPath);
|
||||
|
||||
Reference in New Issue
Block a user