Files
openchamber/packages/ui/src/lib/opencode/client.ts
T
Bohdan Triapitsyn c703db2745 fix: stop forwarding client auth to OpenCode and harden home/session state
Packaged desktop showed no sessions in 1.12.4. Root cause: the sanitized
session-list proxy path added in #1538 forwarded the renderer's
"authorization" header (the OpenChamber UI client token) to the managed
OpenCode upstream alongside the managed "Authorization" credential.
OpenCode does not recognize UI client tokens, so every session-list
request answered 401 — only in the packaged app, because only its
renderer (openchamber-ui:// origin) attaches a bearer token; dev web and
dev Electron run same-origin without one. The legacy http-proxy path
overwrote the header correctly, which is why everything except session
lists kept working.

Proxy fix:
- proxy-headers: filter the client "authorization" header out of
  forwarded request headers; the OpenCode upstream must only ever see
  its own managed credentials. Covered by tests.

Desktop cwd:
- electron: launch the managed OpenCode CLI from the user home instead
  of app userData, matching upstream desktop behavior. userData-as-cwd
  made OpenCode treat the app-data folder as a separate empty workspace.

Home directory poisoning loop:
- directoryPersistence: stop replaying localStorage homeDirectory
  through synchronizeHomeDirectory on boot/auth resync. The persisted
  value is only a boot-time cache; replaying it re-wrote stale values
  (e.g. a project path) into desktop settings on every start, overriding
  the authoritative /api/fs/home resolution.
- persistence: never overwrite an injected window.__OPENCHAMBER_HOME__
  with a persisted value.
- useDirectoryStore: host switches happen in place (no reload), so
  re-resolve home from the new runtime's /api/fs/home on endpoint
  change instead of keeping the previous host's value.
- opencode client: only short-circuit to the injected desktop home when
  the active runtime is local; remote runtimes ask /api/fs/home.

Settings hygiene:
- persistSettings: log field names only — change payloads can carry
  credentials (UI password, client tokens, tunnel tokens) that must not
  reach the log file; drop step-by-step log chatter.
- validateProjectEntries: only stat project paths when the incoming
  update actually touches the projects list, not on every settings save.
- remove the write-only approvedDirectories setting everywhere and add
  a migration that strips the stale key from persisted settings.

Tests:
- usePluginsStore.test: register an own runtime-fetch module mock so the
  suite is independent of process-global mock.module leakage from other
  files, and restore globalThis.fetch after the suite.
- persistence.test: clean up the window global created for the suite.
2026-06-12 01:53:38 +03:00

1722 lines
58 KiB
TypeScript

import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
import type { FilesAPI } from "../api/types";
import { getDesktopHomeDirectory } from "../desktop";
import type {
Session,
Message,
Part,
Provider,
Config,
Model,
Agent,
TextPartInput,
FilePartInput,
} from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
import { getRuntimeUrlResolver } from "@/lib/runtime-url";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { getRuntimeKey } from "@/lib/runtime-switch";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { markStartupTrace } from "@/lib/startupTrace";
import {
assertProviderCircuitClosed,
recordProviderSuccess,
recordProviderError,
shouldRetry,
getRetryDelayMs,
} from "./provider-tracker";
// Use relative path by default (works with both dev and nginx proxy server)
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
/**
* Render an SDK error payload into a short string for Error messages.
* The SDK returns `{data, error}` shape without throwing on non-2xx; methods
* that need to signal failure (so callers can preserve state instead of
* conflating failure with an empty success) wrap the error with this helper.
*/
function formatSdkError(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") {
return (error as { message: string }).message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
type SdkResult<T> = {
data?: T;
error?: unknown;
response?: { status?: number };
};
function unwrapSdkData<T>(result: SdkResult<T>, operation: string): T {
if (result.error) {
const status = result.response?.status;
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number };
if (status !== undefined) error.status = status;
throw error;
}
if (result.data === undefined || result.data === null) {
throw new Error(`${operation} failed: empty response`);
}
return result.data;
}
function unwrapSdkOptional<T>(result: SdkResult<T>, operation: string): T | undefined {
if (result.error) {
const status = result.response?.status;
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number };
if (status !== undefined) error.status = status;
throw error;
}
return result.data;
}
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
const ID_RANDOM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const ID_RANDOM_LENGTH = 14;
let lastIdTimestamp = 0;
let idCounter = 0;
const randomBase62 = (length: number): string => {
const bytes = new Uint8Array(length);
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
let result = "";
for (let index = 0; index < length; index += 1) {
result += ID_RANDOM_CHARS[bytes[index] % ID_RANDOM_CHARS.length];
}
return result;
};
const ascendingId = (prefix: "msg"): string => {
const timestamp = Date.now();
if (timestamp !== lastIdTimestamp) {
lastIdTimestamp = timestamp;
idCounter = 0;
}
idCounter += 1;
const sortable = BigInt(timestamp) * BigInt(0x1000) + BigInt(idCounter);
const timeBytes = new Uint8Array(6);
for (let index = 0; index < 6; index += 1) {
timeBytes[index] = Number((sortable >> BigInt(40 - 8 * index)) & BigInt(0xff));
}
const hex = Array.from(timeBytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${prefix}_${hex}${randomBase62(ID_RANDOM_LENGTH)}`;
};
const isRetryableFetchError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === 'AbortError') return true;
if (error instanceof TypeError) return true;
return false;
};
const ensureAbsoluteBaseUrl = (candidate: string): string => {
const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api";
if (ABSOLUTE_URL_PATTERN.test(normalized)) {
return normalized;
}
if (typeof window === "undefined") {
return normalized;
}
const baseReference = window.location?.href || window.location?.origin;
if (!baseReference) {
return normalized;
}
try {
return new URL(normalized, baseReference).toString();
} catch (error) {
console.warn("Failed to normalize OpenCode base URL:", error);
return normalized;
}
};
const resolveRuntimeBaseUrl = (): string | null => {
try {
return getRuntimeUrlResolver().api('/api');
} catch {
return null;
}
};
const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => {
return createOpencodeClient({
...config,
fetch: runtimeFetch,
});
};
interface App {
version?: string;
[key: string]: unknown;
}
export type FilesystemEntry = {
name: string;
path: string;
isDirectory: boolean;
isFile: boolean;
isSymbolicLink?: boolean;
};
export type ProjectFileSearchHit = {
name: string;
path: string;
relativePath: string;
extension?: string;
};
type AgentPartInputLite = {
type: 'agent';
name: string;
source?: {
value: string;
start: number;
end: number;
};
};
type FileInputLite = {
id?: string;
type: 'file';
mime: string;
filename?: string;
url: string;
};
export type DirectorySwitchResult = {
success: boolean;
restarted: boolean;
path: string;
agents?: Agent[];
providers?: Provider[];
models?: unknown[];
};
const normalizeFsPath = (path: string): string => path.replace(/\\/g, "/");
const FS_LIST_CACHE_TTL_MS = 400;
const getDesktopFilesApi = (): FilesAPI | null => {
const apis = getRegisteredRuntimeAPIs();
if (apis && apis.runtime?.isDesktop && apis.files) {
return apis.files;
}
return null;
};
class OpencodeService {
private client: OpencodeClient;
private baseUrl: string;
private scopedClients: Map<string, OpencodeClient> = new Map();
private currentDirectory: string | undefined = undefined;
private directoryContextQueue: Promise<void> = Promise.resolve();
private listDirectoryInFlight: Map<string, Promise<FilesystemEntry[]>> = new Map();
private listDirectoryCache: Map<string, { entries: FilesystemEntry[]; expiresAt: number }> = new Map();
constructor(baseUrl: string = DEFAULT_BASE_URL) {
const runtimeBase = resolveRuntimeBaseUrl();
const requestedBaseUrl = runtimeBase || baseUrl;
this.baseUrl = ensureAbsoluteBaseUrl(requestedBaseUrl);
this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl });
}
getBaseUrl(): string {
return this.baseUrl;
}
reconnectToRuntimeBaseUrl(): void {
const runtimeBase = resolveRuntimeBaseUrl();
const nextBaseUrl = ensureAbsoluteBaseUrl(runtimeBase || DEFAULT_BASE_URL);
if (nextBaseUrl === this.baseUrl) {
return;
}
this.baseUrl = nextBaseUrl;
this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl });
this.scopedClients.clear();
this.listDirectoryInFlight.clear();
this.listDirectoryCache.clear();
}
/** Expose the raw SDK client for direct use (e.g., SyncProvider) */
getSdkClient(): OpencodeClient {
return this.client;
}
/** Get a scoped SDK client for a specific directory */
getScopedSdkClient(directory: string): OpencodeClient {
return this.getScopedApiClient(directory);
}
/**
* Returns an SDK client scoped to a project directory.
* Needed for worktree APIs where backend ignores per-call directory.
*/
getScopedApiClient(directory: string): OpencodeClient {
const normalized = this.normalizeCandidatePath(directory) ?? directory;
const key = normalized || '';
const existing = this.scopedClients.get(key);
if (existing) {
return existing;
}
const scoped = createRuntimeOpencodeClient({ baseUrl: this.baseUrl, directory: normalized });
this.scopedClients.set(key, scoped);
return scoped;
}
private normalizeCandidatePath(path?: string | null): string | null {
if (typeof path !== 'string') {
return null;
}
const trimmed = path.trim();
if (!trimmed) {
return null;
}
// Normalize backslashes and uppercase the Windows drive letter so that
// d:\MyProject and D:\MyProject resolve to the same canonical form.
const normalized = trimmed
.replace(/\\/g, '/')
.replace(/^([a-z]):/, (_, letter: string) => letter.toUpperCase() + ':');
const withoutTrailingSlash = normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
return withoutTrailingSlash || null;
}
private deriveHomeDirectory(path: string): { homeDirectory: string; username?: string } {
const windowsMatch = path.match(/^([A-Za-z]:)(?:\/|$)/);
if (windowsMatch) {
const drive = windowsMatch[1];
const remainder = path.slice(drive.length + (path.charAt(drive.length) === '/' ? 1 : 0));
const segments = remainder.split('/').filter(Boolean);
if (segments.length >= 2) {
const homeDirectory = `${drive}/${segments[0]}/${segments[1]}`;
return { homeDirectory, username: segments[1] };
}
if (segments.length === 1) {
const homeDirectory = `${drive}/${segments[0]}`;
return { homeDirectory, username: segments[0] };
}
return { homeDirectory: drive, username: undefined };
}
const absolute = path.startsWith('/');
const segments = path.split('/').filter(Boolean);
if (segments.length >= 2 && (segments[0] === 'Users' || segments[0] === 'home')) {
const homeDirectory = `${absolute ? '/' : ''}${segments[0]}/${segments[1]}`;
return { homeDirectory, username: segments[1] };
}
if (absolute) {
if (segments.length === 0) {
return { homeDirectory: '/', username: undefined };
}
const homeDirectory = `/${segments.join('/')}`;
return { homeDirectory, username: segments[segments.length - 1] };
}
if (segments.length > 0) {
const homeDirectory = `/${segments.join('/')}`;
return { homeDirectory, username: segments[segments.length - 1] };
}
return { homeDirectory: '/', username: undefined };
}
// Set the current working directory for all API calls
setDirectory(directory: string | undefined) {
const normalized = this.normalizeCandidatePath(directory) ?? directory;
if (this.currentDirectory !== normalized) {
markStartupTrace('opencodeClient:setDirectory', {
previous: this.currentDirectory ?? null,
next: normalized ?? null,
});
}
this.currentDirectory = normalized;
}
getDirectory(): string | undefined {
return this.currentDirectory;
}
async withDirectory<T>(directory: string | undefined | null, fn: () => Promise<T>): Promise<T> {
const runWithContext = async (): Promise<T> => {
if (directory === undefined || directory === null) {
return fn();
}
const previousDirectory = this.currentDirectory;
const scopedDirectory = this.normalizeCandidatePath(directory) ?? directory;
this.currentDirectory = scopedDirectory;
try {
return await fn();
} finally {
if (this.currentDirectory === scopedDirectory) {
this.currentDirectory = previousDirectory;
}
}
};
const queuedRun = this.directoryContextQueue.then(runWithContext, runWithContext);
this.directoryContextQueue = queuedRun.then(
() => undefined,
() => undefined,
);
return queuedRun;
}
// Get the raw API client for direct access
getApiClient(): OpencodeClient {
return this.client;
}
// Get system information including home directory
async getSystemInfo(): Promise<{ homeDirectory: string; username?: string }> {
const candidates = new Set<string>();
const addCandidate = (value?: string | null) => {
const normalized = this.normalizeCandidatePath(value);
if (normalized) {
candidates.add(normalized);
}
};
try {
const response = await this.client.path.get(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
const info = response.data;
if (info) {
addCandidate(info.directory);
addCandidate(info.worktree);
addCandidate(info.state);
}
} catch (error) {
console.debug('Failed to load path info:', error);
}
if (!candidates.size) {
try {
const project = await this.client.project.current(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
addCandidate(project.data?.worktree);
} catch (error) {
console.debug('Failed to load project info:', error);
}
}
if (!candidates.size) {
try {
const sessions = await this.listSessions();
sessions.forEach((session) => addCandidate(session.directory));
} catch (error) {
console.debug('Failed to inspect sessions for system info:', error);
}
}
addCandidate(this.currentDirectory);
if (typeof window !== 'undefined') {
try {
addCandidate(window.localStorage.getItem('lastDirectory'));
addCandidate(window.localStorage.getItem('homeDirectory'));
} catch {
// Access to storage failed (e.g. privacy mode)
}
}
if (!candidates.size && typeof process !== 'undefined' && typeof process.cwd === 'function') {
addCandidate(process.cwd());
}
if (!candidates.size) {
return { homeDirectory: '/', username: undefined };
}
const [primary] = Array.from(candidates);
return this.deriveHomeDirectory(primary);
}
/**
* Best-effort probe whether a directory is accessible to OpenCode.
* This is intentionally NOT the same as local filesystem access in the UI runtime.
*/
async probeDirectory(directory: string): Promise<boolean> {
const normalized = this.normalizeCandidatePath(directory);
if (!normalized) {
return false;
}
try {
const response = await this.client.path.get({ directory: normalized });
const info = response.data as { directory?: unknown } | undefined;
const returned = typeof info?.directory === 'string' ? info.directory : null;
return Boolean(returned && returned.trim().length > 0);
} catch {
return false;
}
}
// Session Management
async listSessions(): Promise<Session[]> {
const response = await this.client.session.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
return Array.isArray(response.data) ? response.data : [];
}
async createSession(params?: { parentID?: string; title?: string; metadata?: Record<string, unknown> }, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.create({
...(requestDirectory ? { directory: requestDirectory } : {}),
parentID: params?.parentID,
title: params?.title,
metadata: params?.metadata,
});
return unwrapSdkData(response, 'session.create');
}
async getSession(id: string, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.get({
sessionID: id,
...(requestDirectory ? { directory: requestDirectory } : {})
});
return unwrapSdkData(response, 'session.get');
}
async deleteSession(id: string, directory?: string | null): Promise<boolean> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.delete({
sessionID: id,
...(requestDirectory ? { directory: requestDirectory } : {}),
});
return unwrapSdkOptional(response, 'session.delete') === true;
}
async updateSession(
id: string,
patch: { title?: string; metadata?: Record<string, unknown>; time?: { archived?: number | null } },
directory?: string | null,
): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const sdkPatch = {
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}),
...(patch.time?.archived !== undefined && patch.time.archived !== null ? { time: { archived: patch.time.archived } } : {}),
};
const response = await this.client.session.update({
sessionID: id,
...(requestDirectory ? { directory: requestDirectory } : {}),
...sdkPatch,
});
return unwrapSdkData(response, 'session.update');
}
async getSessionMessages(id: string, limit?: number): Promise<{ info: Message; parts: Part[] }[]> {
const response = await this.client.session.messages({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
...(typeof limit === 'number' ? { limit } : {}),
});
return unwrapSdkData(response, 'session.messages');
}
async getSessionTodos(sessionId: string): Promise<Array<{ id: string; content: string; status: string; priority: string }>> {
try {
const response = await this.client.session.todo({
sessionID: sessionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
if (response.error) {
return [];
}
const data = response.data;
if (!data || !Array.isArray(data)) {
return [];
}
return data as Array<{ id: string; content: string; status: string; priority: string }>;
} catch {
return [];
}
}
/**
* Check if MIME type needs normalization to text/plain.
* Some text MIME types (like text/markdown) aren't supported by AI providers.
*/
private shouldNormalizeToTextPlain(mime: string): boolean {
if (!mime) return false;
const lowerMime = mime.toLowerCase();
// All text/* types except text/plain need normalization
if (lowerMime.startsWith('text/') && lowerMime !== 'text/plain') {
return true;
}
// Common application types that are actually text
const textBasedTypes = [
'application/json',
'application/xml',
'application/javascript',
'application/typescript',
'application/x-yaml',
'application/yaml',
'application/toml',
'application/x-sh',
'application/x-shellscript',
'application/octet-stream',
'image/svg+xml',
];
return textBasedTypes.includes(lowerMime);
}
/**
* Check if MIME type is HEIC/HEIF (iPhone photo format).
*/
private isHeicMime(mime: string): boolean {
if (!mime) return false;
const lowerMime = mime.toLowerCase();
return lowerMime === 'image/heic' || lowerMime === 'image/heif';
}
/**
* Convert HEIC image to JPEG.
* Returns the original file if conversion fails.
*/
private async convertHeicToJpeg(file: { mime: string; filename?: string; url: string }): Promise<{ mime: string; filename?: string; url: string }> {
try {
// Dynamic import to avoid loading heic2any unless needed
const heic2any = (await import('heic2any')).default;
// Extract base64 data from data URL
const commaIndex = file.url.indexOf(',');
if (commaIndex === -1) return file;
const base64Data = file.url.substring(commaIndex + 1);
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const heicBlob = new Blob([bytes], { type: file.mime });
// Convert to JPEG
const jpegBlob = await heic2any({
blob: heicBlob,
toType: 'image/jpeg',
quality: 0.9,
}) as Blob;
// Convert back to data URL
const jpegDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(jpegBlob);
});
// Update filename extension
let newFilename = file.filename;
if (newFilename) {
newFilename = newFilename.replace(/\.heic$/i, '.jpg').replace(/\.heif$/i, '.jpg');
}
return {
mime: 'image/jpeg',
filename: newFilename,
url: jpegDataUrl
};
} catch (error) {
console.warn('Failed to convert HEIC to JPEG:', error);
return file;
}
}
/**
* Normalize file part for sending to AI providers.
* - Converts unsupported text MIME types to text/plain
* - Converts HEIC/HEIF images to JPEG
*/
private async normalizeFilePart(file: { mime: string; filename?: string; url: string }): Promise<{ mime: string; filename?: string; url: string }> {
// Handle HEIC conversion
if (this.isHeicMime(file.mime)) {
return this.convertHeicToJpeg(file);
}
// Handle text MIME normalization
if (!this.shouldNormalizeToTextPlain(file.mime)) {
return file;
}
let normalizedUrl = file.url;
// Update MIME type in data URL if present
// Format: data:<mime>;base64,<content> or data:<mime>,<content>
if (file.url.startsWith('data:')) {
const commaIndex = file.url.indexOf(',');
if (commaIndex !== -1) {
const meta = file.url.substring(5, commaIndex); // after "data:"
const content = file.url.substring(commaIndex); // includes comma
// Replace the MIME type in meta, preserving ;base64 if present
const newMeta = meta.replace(/^[^;,]+/, 'text/plain');
normalizedUrl = `data:${newMeta}${content}`;
}
}
return {
mime: 'text/plain',
filename: file.filename,
url: normalizedUrl
};
}
private async toNormalizedFilePartInput(file: FileInputLite): Promise<FilePartInput> {
const normalized = await this.normalizeFilePart(file);
return {
...(file.id ? { id: file.id } : {}),
type: 'file',
mime: normalized.mime,
filename: normalized.filename,
url: normalized.url,
};
}
async sendMessage(params: {
id: string;
providerID: string;
modelID: string;
text: string;
prefaceText?: string;
prefaceTextSynthetic?: boolean;
agent?: string;
variant?: string;
files?: Array<FileInputLite>;
/** Additional text/file parts to include (for batch sending queued messages) */
additionalParts?: Array<{
text: string;
synthetic?: boolean;
files?: Array<FileInputLite>;
}>;
messageId?: string;
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
format?: {
type: 'json_schema';
schema: Record<string, unknown>;
retryCount?: number;
};
directory?: string | null;
}): Promise<string> {
// Reuse one client-side message ID across retries. The server accepts this
// as the real user message ID, making ambiguous network retries idempotent.
const messageId = params.messageId ?? ascendingId("msg");
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
const parts: Array<TextPartInput | FilePartInput | AgentPartInputLite> = [];
if (params.prefaceText && params.prefaceText.trim()) {
parts.push({
type: 'text',
text: params.prefaceText,
synthetic: params.prefaceTextSynthetic !== false,
});
}
// Add text part if there's content
if (params.text && params.text.trim()) {
const textPart: TextPartInput = {
type: 'text',
text: params.text
};
parts.push(textPart);
}
// Add file parts if provided (normalizing MIME types for compatibility)
if (params.files && params.files.length > 0) {
for (const file of params.files) {
const filePart = await this.toNormalizedFilePartInput(file);
parts.push(filePart);
}
}
// Add additional parts (for batch/queued messages)
if (params.additionalParts && params.additionalParts.length > 0) {
for (const additional of params.additionalParts) {
if (additional.text && additional.text.trim()) {
parts.push({
type: 'text',
text: additional.text,
...(additional.synthetic ? { synthetic: true } : {}),
});
}
if (additional.files && additional.files.length > 0) {
for (const file of additional.files) {
const filePart = await this.toNormalizedFilePartInput(file);
parts.push(filePart);
}
}
}
}
if (params.agentMentions && params.agentMentions.length > 0) {
for (const mention of params.agentMentions) {
if (!mention?.name) continue;
parts.push({
type: 'agent',
name: mention.name,
...(mention.source ? { source: mention.source } : {}),
});
}
}
// Ensure we have at least one part
if (parts.length === 0) {
throw new Error('Message must have at least one part (text or file)');
}
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
if (params.format) {
console.info('[git-generation][browser] send structured message', {
sessionId: params.id,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
variant: params.variant,
directory: requestDirectory,
baseUrl: this.baseUrl,
formatType: params.format.type,
});
}
assertProviderCircuitClosed(params.providerID);
let response!: Response;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const result = await this.client.session.promptAsync({
sessionID: params.id,
...(requestDirectory ? { directory: requestDirectory } : {}),
model: {
providerID: params.providerID,
modelID: params.modelID,
},
agent: params.agent,
variant: params.variant,
messageID: messageId,
...(params.format ? { format: params.format } : {}),
parts,
});
if (result.response instanceof Response) {
response = result.response;
} else if (result.error) {
const status = (result as SdkResult<unknown>).response?.status || 500;
response = new Response(JSON.stringify(result.error), { status });
} else {
response = new Response(JSON.stringify(result.data ?? true), { status: 200 });
}
} catch (error) {
if (attempt < 2 && isRetryableFetchError(error)) {
const delay = getRetryDelayMs(attempt);
console.warn(
`[prompt] fetch failed for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms`,
(error as Error)?.message
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
recordProviderError(params.providerID);
throw error;
}
if (response.ok) {
recordProviderSuccess(params.providerID);
return messageId;
}
if (shouldRetry(params.providerID, response.status, attempt)) {
const delay = getRetryDelayMs(attempt);
console.warn(
`[prompt] ${response.status} for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms`
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
const error = new Error(`Failed to send message (${response.status})${suffix}`);
recordProviderError(params.providerID, response.status);
throw error;
}
// Defensive fallback — all loop paths return/throw, but TypeScript
// control flow analysis cannot prove exhaustiveness without this.
throw new Error('Failed to send message after retries');
}
async sendCommand(params: {
id: string;
providerID: string;
modelID: string;
command: string;
arguments?: string;
agent?: string;
variant?: string;
files?: Array<FileInputLite>;
messageId?: string;
directory?: string | null;
}): Promise<string> {
const tempMessageId = params.messageId ?? ascendingId("msg");
const parts: FilePartInput[] = [];
if (params.files && params.files.length > 0) {
for (const file of params.files) {
parts.push(await this.toNormalizedFilePartInput(file));
}
}
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
const response = await this.client.session.command({
sessionID: params.id,
...(requestDirectory ? { directory: requestDirectory } : {}),
command: params.command,
arguments: params.arguments ?? '',
model: `${params.providerID}/${params.modelID}`,
agent: params.agent,
variant: params.variant,
...(parts.length > 0 ? { parts } : {}),
messageID: tempMessageId,
});
unwrapSdkOptional(response, 'session.command');
return tempMessageId;
}
async abortSession(id: string): Promise<boolean> {
const response = await this.client.session.abort(
{
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
},
{ throwOnError: true }
);
return Boolean(response.data);
}
async shellSession(params: {
sessionId: string;
command: string;
agent: string;
model: { providerID: string; modelID: string };
messageId?: string;
directory?: string | null;
}): Promise<{ info: Message; parts: Part[] }> {
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
const response = await this.client.session.shell({
sessionID: params.sessionId,
...(requestDirectory ? { directory: requestDirectory } : {}),
messageID: params.messageId,
agent: params.agent,
model: params.model,
command: params.command,
});
return unwrapSdkData(response, 'session.shell') as { info: Message; parts: Part[] };
}
async revertSession(sessionId: string, messageId: string, partId?: string, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.revert({
sessionID: sessionId,
...(requestDirectory ? { directory: requestDirectory } : {}),
messageID: messageId,
partID: partId,
});
return unwrapSdkData(response, 'session.revert');
}
async summarizeSession(sessionId: string, providerId: string, modelId: string, directory?: string | null): Promise<boolean> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.summarize({
sessionID: sessionId,
...(requestDirectory ? { directory: requestDirectory } : {}),
providerID: providerId,
modelID: modelId,
});
return unwrapSdkOptional(response, 'session.summarize') === true;
}
async unrevertSession(sessionId: string): Promise<Session> {
const response = await this.client.session.unrevert({
sessionID: sessionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
});
return unwrapSdkData(response, 'session.unrevert');
}
async forkSession(sessionId: string, messageId?: string, directory?: string | null): Promise<Session> {
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.session.fork({
sessionID: sessionId,
...(requestDirectory ? { directory: requestDirectory } : {}),
messageID: messageId,
});
return unwrapSdkData(response, 'session.fork');
}
async getSessionStatus(): Promise<
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
> {
return (await this.getSessionStatusForDirectory(this.currentDirectory ?? null)) ?? {};
}
/**
* Returns the upstream `/session/status` map, or `null` if the fetch failed.
*
* `null` vs `{}` matters for reconnect resync: the server omits idle sessions
* from the response, so an empty `{}` means "everything is idle" and a candidate
* missing from the response is authoritatively idle. A network/HTTP failure must
* not be conflated with that — return `null` so the caller can preserve state.
*/
async getSessionStatusForDirectory(
directory: string | null | undefined
): Promise<Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }> | null> {
try {
const trimmedDirectory = typeof directory === "string" ? directory.trim() : "";
const result = await this.client.session.status(trimmedDirectory ? { directory: trimmedDirectory } : undefined);
if (result.error || !result.data || typeof result.data !== "object") {
return null;
}
return result.data as Record<
string,
{ type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }
>;
} catch {
return null;
}
}
async getGlobalSessionStatus(): Promise<
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
> {
return (await this.getSessionStatusForDirectory(null)) ?? {};
}
/**
* Get session activity from web server's in-memory tracking.
* This is more reliable than getGlobalSessionStatus on visibility restore
* because the web server tracks activity even when UI is not listening to SSE.
*/
async getWebServerSessionActivity(): Promise<
Record<string, { type: string }> | null
> {
try {
const response = await runtimeFetch('/api/session-activity', {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (!data || typeof data !== 'object') {
return null;
}
return data as Record<string, { type: string }>;
} catch {
return null;
}
}
// Tools
async listToolIds(options?: { directory?: string | null }): Promise<string[]> {
try {
const directory = typeof options?.directory === 'string'
? options.directory.trim()
: (this.currentDirectory ? this.currentDirectory.trim() : '');
const result = await this.client.tool.ids(directory ? { directory } : undefined);
const tools = (result.data || []) as unknown as string[];
return tools.filter((tool) => typeof tool === 'string' && tool !== 'invalid');
} catch {
return [];
}
}
// Permissions
async replyToPermission(
requestId: string,
reply: 'once' | 'always' | 'reject',
options?: { message?: string; directory?: string | null }
): Promise<boolean> {
const requestDirectory = this.normalizeCandidatePath(options?.directory ?? null) ?? this.currentDirectory;
const response = await this.client.permission.reply({
requestID: requestId,
...(requestDirectory ? { directory: requestDirectory } : {}),
reply,
...(options?.message ? { message: options.message } : {}),
});
return unwrapSdkOptional(response, 'permission.reply') === true;
}
/**
* Throws on fetch/SDK failure. Callers that drive authoritative state from
* the result (e.g. reconnect resync) must let the throw propagate so they
* can preserve existing state instead of conflating "fetch failed" with
* "server returned no pending permissions".
*/
async listPendingPermissions(options?: { directories?: Array<string | null | undefined> }): Promise<PermissionRequest[]> {
const fetches: Array<Promise<PermissionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<PermissionRequest[]> => {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.permission.list(trimmed ? { directory: trimmed } : undefined);
if (result.error) {
throw new Error(`permission.list failed: ${formatSdkError(result.error)}`);
}
return (result.data || []) as unknown as PermissionRequest[];
};
// Try unscoped first (server may return global pending items).
fetches.push(fetchForDirectory(null));
const uniqueDirectories = new Set<string>();
for (const entry of options?.directories ?? []) {
const normalized = this.normalizeCandidatePath(entry ?? null);
if (normalized) {
uniqueDirectories.add(normalized);
}
}
for (const directory of uniqueDirectories) {
fetches.push(fetchForDirectory(directory));
}
const results = await Promise.all(fetches);
const merged: PermissionRequest[] = [];
const seenIds = new Set<string>();
for (const list of results) {
for (const item of list) {
if (!item || typeof item !== 'object') continue;
const id = (item as { id?: unknown }).id;
if (typeof id !== 'string' || id.length === 0) continue;
if (seenIds.has(id)) continue;
seenIds.add(id);
merged.push(item);
}
}
return merged;
}
// Questions ("ask" tool)
async replyToQuestion(requestId: string, answers: string[] | string[][], directory?: string | null): Promise<boolean> {
const normalizedAnswers: string[][] = (() => {
if (!Array.isArray(answers) || answers.length === 0) {
return [];
}
if (Array.isArray(answers[0])) {
return answers as string[][];
}
return [answers as string[]];
})();
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
const response = await this.client.question.reply({
requestID: requestId,
...(requestDirectory ? { directory: requestDirectory } : {}),
answers: normalizedAnswers,
});
return unwrapSdkOptional(response, 'question.reply') === true;
}
async rejectQuestion(requestId: string): Promise<boolean> {
const result = await this.client.question.reject({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
return unwrapSdkOptional(result, 'question.reject') === true;
}
/**
* Throws on fetch/SDK failure. See {@link listPendingPermissions} for
* rationale — resync paths preserve state on throw via outer try/catch
* instead of conflating failure with an empty server response.
*/
async listPendingQuestions(options?: { directories?: Array<string | null | undefined> }): Promise<QuestionRequest[]> {
const fetches: Array<Promise<QuestionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<QuestionRequest[]> => {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.question.list(trimmed ? { directory: trimmed } : undefined);
if (result.error) {
throw new Error(`question.list failed: ${formatSdkError(result.error)}`);
}
return (result.data || []) as unknown as QuestionRequest[];
};
// Try unscoped first (server may return global pending items).
fetches.push(fetchForDirectory(null));
const uniqueDirectories = new Set<string>();
for (const entry of options?.directories ?? []) {
const normalized = this.normalizeCandidatePath(entry ?? null);
if (normalized) {
uniqueDirectories.add(normalized);
}
}
for (const directory of uniqueDirectories) {
fetches.push(fetchForDirectory(directory));
}
const results = await Promise.all(fetches);
const merged: QuestionRequest[] = [];
const seenIds = new Set<string>();
for (const list of results) {
for (const item of list) {
if (!item || typeof item !== 'object') continue;
const id = (item as { id?: unknown }).id;
if (typeof id !== 'string' || id.length === 0) continue;
if (seenIds.has(id)) continue;
seenIds.add(id);
merged.push(item);
}
}
return merged;
}
// Configuration
async getConfig(): Promise<Config> {
const response = await this.client.config.get();
if (!response.data) throw new Error('Failed to get config');
return response.data;
}
async updateConfig(config: Record<string, unknown>): Promise<Config> {
// IMPORTANT: Do NOT pass directory parameter for config updates
// The config should be global, not directory-specific
const response = await this.client.config.update({ config: config as Config });
return unwrapSdkData(response, 'global.config.update');
}
/**
* Update config with a partial modification function.
* This handles the GET-modify-PATCH pattern required by the upstream API.
*
* NOTE: This method is deprecated for agent configuration.
* Use backend endpoints at /api/config/agents/* instead, which write directly to files.
*
* @param modifier Function that receives current config and returns modified config
* @returns Updated config from server
*/
async updateConfigPartial(modifier: (config: Config) => Config): Promise<Config> {
const currentConfig = await this.getConfig();
const updatedConfig = modifier(currentConfig);
const result = await this.updateConfig(updatedConfig);
return result;
}
async getProviders(): Promise<{
providers: Provider[];
default: { [key: string]: string };
}> {
const response = await this.client.config.providers(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
if (!response.data) throw new Error('Failed to get providers');
return response.data;
}
// App Management - using config endpoint since /app doesn't exist in this version
async getApp(): Promise<App> {
// Return basic app info from config
const config = await this.getConfig();
return {
version: "0.0.3", // from the OpenAPI spec
config
};
}
async initApp(): Promise<boolean> {
try {
// Just check if we can connect since there's no init endpoint
return await this.checkHealth();
} catch {
return false;
}
}
// Agent Management
/**
* Throws on fetch/SDK failure so caller-side retry loops (see
* useAgentsStore) can observe failure and retry; silently returning an
* empty list would defeat retries and clear the cached agent list.
*/
async listAgents(): Promise<Agent[]> {
const response = await this.client.app.agents(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
if (response.error) {
throw new Error(`app.agents failed: ${formatSdkError(response.error)}`);
}
return response.data || [];
}
// SSE infrastructure removed — EventPipeline in sync/event-pipeline.ts handles
// all SSE event ingestion via the SDK's global.event() async iterator.
// File Operations
async readFile(path: string): Promise<string> {
try {
const response = await this.client.file.read({
path,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
return String(unwrapSdkData(response, 'file.read'));
} catch {
// Return placeholder for development
return `// Content of ${path}\n// This would be loaded from the server`;
}
}
async listFiles(directory?: string): Promise<Record<string, unknown>[]> {
try {
const targetDir = directory || this.currentDirectory || '/';
const response = await this.client.file.list({
path: targetDir,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
const data = unwrapSdkData(response, 'file.list');
return Array.isArray(data) ? data as Record<string, unknown>[] : [];
} catch {
// Return mock data for development
return [];
}
}
// Command Management
async listCommands(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string }>> {
const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
const commands = unwrapSdkData(response, 'command.list');
// Return only lightweight info for autocomplete
return (commands || []).map((cmd: Record<string, unknown>) => ({
name: cmd.name as string,
description: cmd.description as string | undefined,
agent: cmd.agent as string | undefined,
model: cmd.model as string | undefined,
source: cmd.source as string | undefined,
// Intentionally excluding template to keep memory usage low
}));
}
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string; template?: string }>> {
const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
const commands = unwrapSdkData(response, 'command.list');
// Return full command details including template
return (commands || []).map((cmd: Record<string, unknown>) => ({
name: cmd.name as string,
description: cmd.description as string | undefined,
agent: cmd.agent as string | undefined,
model: cmd.model as string | undefined,
source: cmd.source as string | undefined,
template: cmd.template as string | undefined,
}));
}
async listSkillsWithDetails(): Promise<Array<{ name: string; description?: string; location: string; content?: string }>> {
try {
const response = await this.client.app.skills(
this.currentDirectory ? { directory: this.currentDirectory } : undefined,
);
const data = response.data;
if (!Array.isArray(data)) {
return [];
}
const skills: Array<{ name: string; description?: string; location: string; content?: string }> = [];
for (const item of data as Array<Record<string, unknown>>) {
const name = typeof item.name === 'string' ? item.name.trim() : '';
const location = typeof item.location === 'string' ? item.location : '';
if (!name || !location) {
continue;
}
const skill: { name: string; description?: string; location: string; content?: string } = { name, location };
if (typeof item.description === 'string') skill.description = item.description;
if (typeof item.content === 'string') skill.content = item.content;
skills.push(skill);
}
return skills;
} catch {
return [];
}
}
async getCommandDetails(name: string): Promise<{ name: string; template: string; description?: string; agent?: string; model?: string } | null> {
try {
const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
if (response.data) {
const command = response.data.find((cmd: Record<string, unknown>) => cmd.name === name);
if (command) {
return {
name: command.name as string,
template: command.template as string,
description: command.description as string | undefined,
agent: command.agent as string | undefined,
model: command.model as string | undefined
};
}
}
return null;
} catch {
return null;
}
}
// Lightweight readiness check. Full diagnostics still live at /health.
async checkHealth(): Promise<boolean> {
try {
const normalizedBase = this.baseUrl.endsWith('/') ? this.baseUrl.replace(/\/+$/, '') : this.baseUrl;
const healthUrl = normalizedBase === '/api' || normalizedBase.endsWith('/api')
? '/api/opencode/health'
: `${normalizedBase}/opencode/health`;
markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl });
const response = await runtimeFetch(healthUrl);
markStartupTrace('opencodeClient.checkHealth:response', { status: response.status });
if (!response.ok) {
return false;
}
const healthData = await response.json();
markStartupTrace('opencodeClient.checkHealth:result', { healthy: healthData?.healthy });
return healthData?.healthy === true;
} catch {
return false;
}
}
// File System Operations
async createDirectory(
dirPath: string,
options?: { allowOutsideWorkspace?: boolean }
): Promise<{ success: boolean; path: string }> {
const desktopFiles = getDesktopFilesApi();
if (desktopFiles?.createDirectory) {
try {
return await desktopFiles.createDirectory(dirPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to create directory');
}
}
const payload = {
path: dirPath,
...(options?.allowOutsideWorkspace ? { allowOutsideWorkspace: true } : {}),
};
const response = await runtimeFetch(`${this.baseUrl}/fs/mkdir`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to create directory' }));
throw new Error(error.error || 'Failed to create directory');
}
const result = await response.json();
return result;
}
async cloneRepository(input: { remoteUrl: string; destinationPath: string; gitIdentityId?: string | null }): Promise<{ success: boolean; path: string; output?: string }> {
const response = await runtimeFetch(`${this.baseUrl}/fs/clone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to clone repository' }));
throw new Error(error.error || 'Failed to clone repository');
}
return await response.json();
}
async listLocalDirectory(directoryPath: string | null | undefined, options?: { respectGitignore?: boolean }): Promise<FilesystemEntry[]> {
const normalizedDirectoryPath = typeof directoryPath === 'string' ? normalizeFsPath(directoryPath.trim()) : '';
const cacheKey = `${normalizedDirectoryPath}|${options?.respectGitignore ? '1' : '0'}`;
const now = Date.now();
const cached = this.listDirectoryCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.entries;
}
const inFlight = this.listDirectoryInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const task = (async () => {
const desktopFiles = getDesktopFilesApi();
if (desktopFiles) {
try {
const result = await desktopFiles.listDirectory(directoryPath || '', options);
if (!result || !Array.isArray(result.entries)) {
return [];
}
const entries = result.entries.map<FilesystemEntry>((entry) => ({
name: entry.name,
path: normalizeFsPath(entry.path),
isDirectory: !!entry.isDirectory,
isFile: !entry.isDirectory,
isSymbolicLink: false,
}));
this.listDirectoryCache.set(cacheKey, {
entries,
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
});
return entries;
} catch (error) {
console.error('Failed to list directory contents:', error);
throw error;
}
}
try {
const params = new URLSearchParams();
if (directoryPath && directoryPath.trim().length > 0) {
params.set('path', directoryPath);
}
if (options?.respectGitignore) {
params.set('respectGitignore', 'true');
}
const query = params.toString();
const response = await runtimeFetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = typeof error.error === 'string' ? error.error : 'Failed to list directory';
throw new Error(message);
}
const result = await response.json();
if (!result || !Array.isArray(result.entries)) {
return [];
}
const entries = result.entries as FilesystemEntry[];
this.listDirectoryCache.set(cacheKey, {
entries,
expiresAt: Date.now() + FS_LIST_CACHE_TTL_MS,
});
return entries;
} catch (error) {
console.error('Failed to list directory contents:', error);
throw error;
}
})();
const trackedTask = task.finally(() => {
if (this.listDirectoryInFlight.get(cacheKey) === trackedTask) {
this.listDirectoryInFlight.delete(cacheKey);
}
});
this.listDirectoryInFlight.set(cacheKey, trackedTask);
return trackedTask;
}
async searchFiles(
query: string,
options?: {
directory?: string | null;
limit?: number;
includeHidden?: boolean;
respectGitignore?: boolean;
dirs?: boolean;
type?: 'file' | 'directory';
}
): Promise<ProjectFileSearchHit[]> {
const directory = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
: this.currentDirectory;
const normalizedDirectory = directory ? normalizeFsPath(directory) : null;
const scopedClient = directory ? this.getScopedApiClient(directory) : this.client;
try {
const response = await scopedClient.find.files({
query,
limit: typeof options?.limit === 'number' && Number.isFinite(options.limit) ? options.limit : undefined,
dirs: options?.dirs === false || options?.type === 'file' ? 'false' : 'true',
type: options?.type,
});
const items = Array.isArray(response?.data) ? response.data : [];
return items.map<ProjectFileSearchHit>((item) => {
const normalizedRelativePath = normalizeFsPath(item);
const name = normalizedRelativePath.split('/').filter(Boolean).pop() || normalizedRelativePath;
const normalizedPath = normalizedDirectory
? normalizeFsPath(`${normalizedDirectory}/${normalizedRelativePath}`)
: normalizeFsPath(normalizedRelativePath);
return {
name,
path: normalizedPath,
relativePath: normalizedRelativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
});
} catch (error) {
console.error('Failed to search files:', error);
throw error;
}
}
async getFilesystemHome(): Promise<string | null> {
// The injected desktop home describes the LOCAL machine. It is only a
// valid answer while the active runtime is the local one — after an
// in-place switch to a remote host the home must come from that host's
// /api/fs/home, not from the local Electron global.
const runtimeKey = getRuntimeKey();
if (!runtimeKey || runtimeKey === 'local') {
const desktopHome = await getDesktopHomeDirectory();
if (desktopHome) {
return desktopHome;
}
}
try {
const response = await runtimeFetch(`${this.baseUrl}/fs/home`, {
method: 'GET',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message =
typeof error.error === 'string' && error.error.length > 0
? error.error
: 'Failed to resolve home directory';
throw new Error(message);
}
const payload = await response.json();
if (payload && typeof payload.home === 'string' && payload.home.length > 0) {
return payload.home;
}
return null;
} catch (error) {
console.warn('Failed to resolve filesystem home directory:', error);
return null;
}
}
async setOpenCodeWorkingDirectory(directoryPath: string | null | undefined): Promise<DirectorySwitchResult | null> {
if (!directoryPath || typeof directoryPath !== 'string' || !directoryPath.trim()) {
console.warn('[OpencodeClient] setOpenCodeWorkingDirectory: invalid path', directoryPath);
return null;
}
const url = `${this.baseUrl}/opencode/directory`;
console.log('[OpencodeClient] POST', url, 'with path:', directoryPath);
try {
const response = await runtimeFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: directoryPath })
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const error = payload ?? {};
const message =
typeof error.error === 'string' && error.error.length > 0
? error.error
: 'Failed to update OpenCode working directory';
throw new Error(message);
}
if (payload && typeof payload === 'object') {
return payload as DirectorySwitchResult;
}
return {
success: true,
restarted: false,
path: directoryPath
};
} catch (error) {
console.warn('Failed to update OpenCode working directory:', error);
throw error;
}
}
}
// Exported singleton instance
export const opencodeClient = new OpencodeService();
// Exported types
export type { Session, Message, Part, Provider, Config, Model };
export type { App };