Files
XCOpenCodeWeb/ui/src/lib/opencode/client.ts

2183 lines
67 KiB
TypeScript

import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
import type { FilesAPI, RuntimeAPIs } from "../api/types";
import { getDesktopHomeDirectory } from "../desktop";
import type {
Session,
Message,
Part,
Provider,
Config,
Model,
Agent,
TextPartInput,
FilePartInput,
Event,
} from "@opencode-ai/sdk/v2";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
type StreamEvent<TData> = {
data: TData;
event?: string;
id?: string;
retry?: number;
};
export type RoutedOpencodeEvent = {
directory: string;
payload: Event;
};
// 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";
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
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 resolveDesktopBaseUrl = (): string | null => {
if (typeof window === "undefined") {
return null;
}
const desktopServer = (window as typeof window & {
__OPENCHAMBER_DESKTOP_SERVER__?: { origin: string; apiPrefix?: string };
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
}).__OPENCHAMBER_DESKTOP_SERVER__;
const isDesktop = Boolean(
(window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__?.runtime?.isDesktop
);
if (!desktopServer || !isDesktop) {
return null;
}
const origin = typeof desktopServer.origin === "string" && desktopServer.origin.length > 0 ? desktopServer.origin : null;
if (!origin) {
return null;
}
return `${origin}/api`;
};
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 getDesktopFilesApi = (): FilesAPI | null => {
if (typeof window === "undefined") {
return null;
}
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
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 sseAbortControllers: Map<string, AbortController> = new Map();
private currentDirectory: string | undefined = undefined;
private directoryContextQueue: Promise<void> = Promise.resolve();
private globalSseAbortController: AbortController | null = null;
private globalSseTask: Promise<void> | null = null;
private globalSseLastEventId: string | undefined;
private globalSseIsConnected = false;
private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set();
private globalSseOpenListeners: Set<() => void> = new Set();
private globalSseErrorListeners: Set<(error: unknown) => void> = new Set();
private globalSseQueue: Array<RoutedOpencodeEvent | undefined> = [];
private globalSseBuffer: Array<RoutedOpencodeEvent | undefined> = [];
private globalSseCoalesced: Map<string, number> = new Map();
private globalSseFlushTimer: ReturnType<typeof setTimeout> | null = null;
private globalSseLastFlushAt = 0;
constructor(baseUrl: string = DEFAULT_BASE_URL) {
const desktopBase = resolveDesktopBaseUrl();
const requestedBaseUrl = desktopBase || baseUrl;
this.baseUrl = ensureAbsoluteBaseUrl(requestedBaseUrl);
this.client = createOpencodeClient({ baseUrl: this.baseUrl });
}
getBaseUrl(): string {
return this.baseUrl;
}
/**
* 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 = createOpencodeClient({ 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;
}
const normalized = trimmed.replace(/\\/g, '/');
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) {
this.currentDirectory = directory;
}
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;
this.currentDirectory = directory;
try {
return await fn();
} finally {
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 }): Promise<Session> {
const response = await this.client.session.create({
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
parentID: params?.parentID,
title: params?.title
});
if (!response.data) throw new Error('Failed to create session');
return response.data;
}
async getSession(id: string): Promise<Session> {
const response = await this.client.session.get({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
});
if (!response.data) throw new Error('Session not found');
return response.data;
}
async deleteSession(id: string): Promise<boolean> {
const response = await this.client.session.delete({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
});
return response.data || false;
}
async updateSession(id: string, title?: string): Promise<Session> {
const response = await this.client.session.update({
sessionID: id,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
title
});
if (!response.data) throw new Error('Failed to update session');
return response.data;
}
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 response.data || [];
}
async getSessionTodos(sessionId: string): Promise<Array<{ id: string; content: string; status: string; priority: string }>> {
try {
const base = this.baseUrl.replace(/\/$/, "");
const url = new URL(`${base}/session/${encodeURIComponent(sessionId)}/todo`);
if (this.currentDirectory && this.currentDirectory.length > 0) {
url.searchParams.set("directory", this.currentDirectory);
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
return [];
}
const data = await response.json().catch(() => null);
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;
};
}): Promise<string> {
// Generate a temporary client-side ID for optimistic UI
// This ID won't be sent to the server - server will generate its own
const baseTimestamp = Date.now();
const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`;
// 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)');
}
// Use async prompt endpoint so the client doesn't block waiting
// for model work (SSE will deliver output/status).
// This avoids 504s from proxy timeouts on long-running turns.
const base = this.baseUrl.replace(/\/+$/, '');
let url: URL;
try {
url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
}
} catch (error) {
console.error('[git-generation][browser] failed to build prompt_async URL', {
baseUrl: this.baseUrl,
normalizedBase: base,
sessionId: params.id,
directory: this.currentDirectory,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
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: this.currentDirectory,
baseUrl: this.baseUrl,
formatType: params.format.type,
});
}
let response: Response;
try {
response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
model: {
providerID: params.providerID,
modelID: params.modelID,
},
agent: params.agent,
variant: params.variant,
...(params.format ? { format: params.format } : {}),
parts,
}),
});
} catch (error) {
console.error('[git-generation][browser] prompt_async request failed before response', {
sessionId: params.id,
url: url.toString(),
directory: this.currentDirectory,
hasFormat: Boolean(params.format),
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
throw new Error(`Failed to send message (${response.status})${suffix}`);
}
// Return temporary ID for optimistic UI
// Real messageID will come from server via SSE events
return tempMessageId;
}
async sendCommand(params: {
id: string;
providerID: string;
modelID: string;
command: string;
arguments?: string;
agent?: string;
variant?: string;
files?: Array<FileInputLite>;
messageId?: string;
}): Promise<string> {
const baseTimestamp = Date.now();
const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`;
const parts: FilePartInput[] = [];
if (params.files && params.files.length > 0) {
for (const file of params.files) {
parts.push(await this.toNormalizedFilePartInput(file));
}
}
const base = this.baseUrl.replace(/\/+$/, '');
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/command`);
if (this.currentDirectory) {
url.searchParams.set('directory', this.currentDirectory);
}
const payload: Record<string, unknown> = {
command: params.command,
arguments: params.arguments ?? '',
model: `${params.providerID}/${params.modelID}`,
...(params.agent ? { agent: params.agent } : {}),
...(params.variant ? { variant: params.variant } : {}),
...(parts.length > 0 ? { parts } : {}),
...(params.messageId ? { messageID: params.messageId } : {}),
};
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
throw new Error(`Failed to run command (${response.status})${suffix}`);
}
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 revertSession(sessionId: string, messageId: string, partId?: string): Promise<Session> {
const response = await this.client.session.revert({
sessionID: sessionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
messageID: messageId,
partID: partId
});
if (!response.data) throw new Error('Failed to revert session');
return response.data;
}
async unrevertSession(sessionId: string): Promise<Session> {
const response = await this.client.session.unrevert({
sessionID: sessionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {})
});
if (!response.data) throw new Error('Failed to unrevert session');
return response.data;
}
async forkSession(sessionId: string, messageId?: string): Promise<Session> {
const response = await this.client.session.fork({
sessionID: sessionId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
messageID: messageId
});
if (!response.data) {
throw new Error('Failed to fork session');
}
return response.data;
}
async getSessionStatus(): Promise<
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
> {
return this.getSessionStatusForDirectory(this.currentDirectory ?? null);
}
async getSessionStatusForDirectory(
directory: string | null | undefined
): Promise<Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>> {
try {
const base = this.baseUrl.replace(/\/$/, "");
const url = new URL(`${base}/session/status`);
const trimmedDirectory = typeof directory === "string" ? directory.trim() : "";
if (trimmedDirectory.length > 0) {
url.searchParams.set("directory", trimmedDirectory);
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
return {};
}
const data = await response.json().catch(() => null);
if (!data || typeof data !== "object") {
return {};
}
return data as Record<
string,
{ type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }
>;
} catch {
return {};
}
}
async getGlobalSessionStatus(): Promise<
Record<string, { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }>
> {
return 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 {
// Web server endpoint - use relative path that works with both dev and prod
const response = await fetch('/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 }
): Promise<boolean> {
const result = await this.client.permission.reply({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
reply,
...(options?.message ? { message: options.message } : {}),
});
return result.data || false;
}
async listPendingPermissions(options?: { directories?: Array<string | null | undefined> }): Promise<PermissionRequest[]> {
const fetches: Array<Promise<PermissionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<PermissionRequest[]> => {
try {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.permission.list(trimmed ? { directory: trimmed } : undefined);
return (result.data || []) as unknown as PermissionRequest[];
} catch {
return [];
}
};
// 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[][]): 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 result = await this.client.question.reply({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
answers: normalizedAnswers,
});
return result.data || false;
}
async rejectQuestion(requestId: string): Promise<boolean> {
const result = await this.client.question.reject({
requestID: requestId,
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
});
return result.data || false;
}
async listPendingQuestions(options?: { directories?: Array<string | null | undefined> }): Promise<QuestionRequest[]> {
const fetches: Array<Promise<QuestionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<QuestionRequest[]> => {
try {
const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.question.list(trimmed ? { directory: trimmed } : undefined);
return (result.data || []) as unknown as QuestionRequest[];
} catch {
return [];
}
};
// 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 url = `${this.baseUrl}/config`;
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
});
if (!response.ok) {
const errorText = await response.text();
console.error('[OpencodeClient] Failed to update config:', response.status, errorText);
throw new Error(`Failed to update config: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data;
}
/**
* 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
async listAgents(): Promise<Agent[]> {
try {
const response = await this.client.app.agents(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
return response.data || [];
} catch {
return [];
}
}
private parseSseBlock(block: string): { data: unknown; id?: string } | null {
if (!block) return null;
const lines = block.split('\n');
const dataLines: string[] = [];
let eventId: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^\s/, ''));
} else if (line.startsWith('id:')) {
const candidate = line.slice(3).trim();
if (candidate) {
eventId = candidate;
}
}
}
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const data = JSON.parse(payloadText) as unknown;
return { data, id: eventId };
} catch {
return null;
}
}
private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const directoryCandidate =
typeof record.directory === 'string'
? record.directory
: typeof record.properties === 'object' && record.properties !== null
? ((record.properties as Record<string, unknown>).directory as unknown)
: null;
const normalizedDirectory =
typeof directoryCandidate === 'string'
? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim()
: null;
if (typeof record.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: record as Event,
};
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
return {
directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global',
payload: nestedRecord as Event,
};
}
}
return null;
}
private emitGlobalSseEvent(event: RoutedOpencodeEvent) {
this.enqueueGlobalSseEvent(event);
}
private notifyGlobalSseOpen() {
for (const handler of this.globalSseOpenListeners) {
try {
handler();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}
private notifyGlobalSseError(error: unknown) {
for (const handler of this.globalSseErrorListeners) {
try {
handler(error);
} catch (listenerError) {
console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError);
}
}
}
private ensureGlobalSseStarted() {
if (this.globalSseTask) {
return;
}
const abortController = new AbortController();
this.globalSseAbortController = abortController;
this.globalSseTask = this.runGlobalSseLoop(abortController)
.catch((error) => {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE task failed:', error);
})
.finally(() => {
if (this.globalSseAbortController === abortController) {
this.globalSseAbortController = null;
}
this.globalSseTask = null;
this.globalSseIsConnected = false;
});
}
private maybeStopGlobalSse() {
if (this.globalSseListeners.size > 0) {
return;
}
if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) {
this.globalSseAbortController.abort();
}
this.globalSseAbortController = null;
this.clearGlobalSseQueue();
}
private clearGlobalSseQueue() {
if (this.globalSseFlushTimer) {
clearTimeout(this.globalSseFlushTimer);
this.globalSseFlushTimer = null;
}
this.globalSseQueue.length = 0;
this.globalSseBuffer.length = 0;
this.globalSseCoalesced.clear();
}
private getGlobalSseCoalesceKey(event: RoutedOpencodeEvent): string | null {
const payload = event.payload as unknown as Record<string, unknown>;
const eventType = typeof payload.type === 'string' ? payload.type : null;
if (!eventType) {
return null;
}
const properties =
typeof payload.properties === 'object' && payload.properties !== null
? (payload.properties as Record<string, unknown>)
: null;
if (eventType === 'session.status') {
const sessionId = typeof properties?.sessionID === 'string'
? properties.sessionID
: typeof properties?.sessionId === 'string'
? properties.sessionId
: null;
if (!sessionId) {
return null;
}
return `session.status:${event.directory}:${sessionId}`;
}
if (eventType === 'openchamber:session-status') {
const sessionId = typeof properties?.sessionId === 'string'
? properties.sessionId
: typeof properties?.sessionID === 'string'
? properties.sessionID
: null;
if (!sessionId) {
return null;
}
return `openchamber:session-status:${sessionId}`;
}
return null;
}
private flushGlobalSseQueue = () => {
if (this.globalSseFlushTimer) {
clearTimeout(this.globalSseFlushTimer);
this.globalSseFlushTimer = null;
}
if (this.globalSseQueue.length === 0) {
return;
}
const events = this.globalSseQueue;
this.globalSseQueue = this.globalSseBuffer;
this.globalSseBuffer = events;
this.globalSseQueue.length = 0;
this.globalSseCoalesced.clear();
this.globalSseLastFlushAt = Date.now();
for (const event of events) {
if (!event) continue;
for (const listener of this.globalSseListeners) {
try {
listener(event);
} catch (error) {
console.warn('[OpencodeClient] Global SSE listener error:', error);
}
}
}
this.globalSseBuffer.length = 0;
};
private scheduleGlobalSseFlush() {
if (this.globalSseFlushTimer) {
return;
}
const elapsed = Date.now() - this.globalSseLastFlushAt;
const delay = Math.max(0, 16 - elapsed);
this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay);
}
private enqueueGlobalSseEvent(event: RoutedOpencodeEvent) {
const key = this.getGlobalSseCoalesceKey(event);
if (key) {
const existingIndex = this.globalSseCoalesced.get(key);
if (existingIndex !== undefined) {
this.globalSseQueue[existingIndex] = undefined;
}
this.globalSseCoalesced.set(key, this.globalSseQueue.length);
}
this.globalSseQueue.push(event);
this.scheduleGlobalSseFlush();
}
private async runGlobalSseLoop(abortController: AbortController): Promise<void> {
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
let attempt = 0;
while (!abortController.signal.aborted) {
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
};
if (this.globalSseLastEventId) {
headers['Last-Event-ID'] = this.globalSseLastEventId;
}
const response = await fetch(globalEndpoint, {
method: 'GET',
headers,
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error(`Global SSE connect failed with status ${response.status}`);
}
attempt = 0;
this.globalSseIsConnected = true;
if (!abortController.signal.aborted) {
this.notifyGlobalSseOpen();
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (abortController.signal.aborted) break;
if (!value || value.length === 0) continue;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const parsed = this.parseSseBlock(block);
if (!parsed) continue;
if (parsed.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = this.normalizeRoutedSsePayload(parsed.data);
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
}
const remaining = buffer.trim();
if (remaining && !abortController.signal.aborted) {
const parsed = this.parseSseBlock(remaining);
if (parsed?.id) {
this.globalSseLastEventId = parsed.id;
}
const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null;
if (routed) {
this.emitGlobalSseEvent(routed);
}
}
// Stream ended; force reconnect.
this.globalSseIsConnected = false;
} catch (error: unknown) {
this.globalSseIsConnected = false;
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
return;
}
console.error('[OpencodeClient] Global SSE stream error (will retry):', error);
this.notifyGlobalSseError(error);
}
if (abortController.signal.aborted) {
break;
}
attempt += 1;
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
this.flushGlobalSseQueue();
}
subscribeToGlobalEvents(
onEvent: (event: RoutedOpencodeEvent) => void,
onError?: (error: unknown) => void,
onOpen?: () => void,
options?: { directory?: string | null }
): () => void {
const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null);
const listener = (event: RoutedOpencodeEvent) => {
if (directoryFilter && event.directory !== directoryFilter) {
return;
}
onEvent(event);
};
this.globalSseListeners.add(listener);
if (onOpen) {
this.globalSseOpenListeners.add(onOpen);
if (this.globalSseIsConnected) {
setTimeout(() => {
if (this.globalSseOpenListeners.has(onOpen)) {
try {
onOpen();
} catch (error) {
console.warn('[OpencodeClient] Global SSE open handler error:', error);
}
}
}, 0);
}
}
if (onError) {
this.globalSseErrorListeners.add(onError);
}
this.ensureGlobalSseStarted();
return () => {
this.globalSseListeners.delete(listener);
if (onOpen) {
this.globalSseOpenListeners.delete(onOpen);
}
if (onError) {
this.globalSseErrorListeners.delete(onError);
}
this.maybeStopGlobalSse();
};
}
// Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator
subscribeToEvents(
onMessage: (event: { type: string; properties?: Record<string, unknown> }) => void,
onError?: (error: unknown) => void,
onOpen?: () => void,
directoryOverride?: string | null,
options?: { scope?: 'global' | 'directory'; key?: string }
): () => void {
const subscriptionKey = options?.key ?? 'default';
const scope = options?.scope ?? 'directory';
const existingController = this.sseAbortControllers.get(subscriptionKey);
if (existingController) {
existingController.abort();
}
// Create new AbortController for this subscription
const abortController = new AbortController();
this.sseAbortControllers.set(subscriptionKey, abortController);
let lastEventId: string | undefined;
if (scope === 'global') {
let globalUnsub: (() => void) | null = null;
const attachDirectory = (event: RoutedOpencodeEvent): Event => {
if (event.directory === 'global') {
return event.payload;
}
const payloadRecord = event.payload as unknown as Record<string, unknown>;
const existingProperties =
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
? (payloadRecord.properties as Record<string, unknown>)
: {};
if (existingProperties.directory === event.directory) {
return event.payload;
}
return {
...payloadRecord,
properties: {
...existingProperties,
directory: event.directory,
},
} as Event;
};
const cleanup = () => {
if (globalUnsub) {
try {
globalUnsub();
} catch {
// ignore
}
globalUnsub = null;
}
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
this.sseAbortControllers.delete(subscriptionKey);
}
};
abortController.signal.addEventListener('abort', cleanup, { once: true });
globalUnsub = this.subscribeToGlobalEvents(
(event) => {
if (abortController.signal.aborted) {
return;
}
onMessage(attachDirectory(event));
},
onError
? (error) => {
if (!abortController.signal.aborted) {
onError(error);
}
}
: undefined,
onOpen
? () => {
if (!abortController.signal.aborted) {
onOpen();
}
}
: undefined,
);
return () => {
cleanup();
abortController.abort();
};
}
const normalizeEventPayload = (payload: unknown): Event | null => {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
if (typeof record.type === 'string') {
return record as Event;
}
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
const nestedRecord = nestedPayload as Record<string, unknown>;
if (typeof nestedRecord.type === 'string') {
if (typeof record.directory === 'string' && record.directory.length > 0) {
const existingProperties =
typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null
? (nestedRecord.properties as Record<string, unknown>)
: null;
const properties = {
...(existingProperties ?? {}),
directory: record.directory,
};
return { ...nestedRecord, properties } as Event;
}
return nestedRecord as Event;
}
}
return null;
};
console.log('[OpencodeClient] Starting SSE subscription...');
// Start async generator in background with reconnect on failure
(async () => {
const resolvedDirectory =
typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
? directoryOverride.trim()
: this.currentDirectory;
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default');
const connect = async (attempt: number): Promise<void> => {
try {
const subscribeParameters = resolvedDirectory ? { directory: resolvedDirectory } : undefined;
const subscribeOptions: {
signal: AbortSignal;
sseDefaultRetryDelay: number;
sseMaxRetryDelay: number;
onSseError?: (error: unknown) => void;
onSseEvent: (event: StreamEvent<unknown>) => void;
headers?: Record<string, string>;
} = {
signal: abortController.signal,
sseDefaultRetryDelay: 3000,
sseMaxRetryDelay: 30000,
onSseError: (error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error('[OpencodeClient] SSE error:', error);
if (onError && !abortController.signal.aborted) {
onError(error);
}
},
onSseEvent: (event: StreamEvent<unknown>) => {
if (abortController.signal.aborted) return;
if (event.id && typeof event.id === 'string') {
lastEventId = event.id;
}
const payload = event.data;
const normalized = normalizeEventPayload(payload);
if (normalized) {
onMessage(normalized);
}
},
};
if (lastEventId) {
subscribeOptions.headers = { ...(subscribeOptions.headers || {}), 'Last-Event-ID': lastEventId };
}
const result = await this.client.event.subscribe(subscribeParameters, subscribeOptions);
if (onOpen && !abortController.signal.aborted) {
console.log('[OpencodeClient] SSE connection opened');
onOpen();
}
for await (const _ of result.stream) {
void _;
if (abortController.signal.aborted) {
console.log('[OpencodeClient] SSE stream aborted');
break;
}
}
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
console.log('[OpencodeClient] SSE stream aborted normally');
return;
}
console.error('[OpencodeClient] SSE stream error (will retry):', error);
if (onError) {
onError(error);
}
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!abortController.signal.aborted) {
await connect(attempt + 1);
}
return;
}
if (!abortController.signal.aborted) {
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
await connect(attempt + 1);
}
};
try {
await connect(0);
} finally {
console.log('[OpencodeClient] SSE subscription cleanup');
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
this.sseAbortControllers.delete(subscriptionKey);
}
}
})();
// Return cleanup function
return () => {
if (this.sseAbortControllers.get(subscriptionKey) === abortController) {
this.sseAbortControllers.delete(subscriptionKey);
}
abortController.abort();
};
}
// File Operations
async readFile(path: string): Promise<string> {
try {
// For now, we'll use a placeholder implementation
// In a real implementation, this would call an API endpoint to read the file
const response = await fetch(`${this.baseUrl}/files/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path,
directory: this.currentDirectory
})
});
if (!response.ok) {
throw new Error(`Failed to read file: ${response.statusText}`);
}
const data = await response.text();
return data;
} 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 fetch(`${this.baseUrl}/files/list`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directory: targetDir })
});
if (!response.ok) {
throw new Error(`Failed to list files: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch {
// Return mock data for development
return [];
}
}
// Command Management
async listCommands(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string }>> {
try {
const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
// Return only lightweight info for autocomplete
return (response.data || []).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
// Intentionally excluding template to keep memory usage low
}));
} catch {
return [];
}
}
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string }>> {
try {
const response = await this.client.command.list(
this.currentDirectory ? { directory: this.currentDirectory } : undefined
);
// Return full command details including template
return (response.data || []).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,
template: cmd.template as string | undefined,
}));
} 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;
}
}
// Health Check - using /health endpoint for detailed status
async checkHealth(): Promise<boolean> {
try {
// Health endpoint is at root, not under /api
let healthUrl: string;
const normalizedBase = this.baseUrl.endsWith('/') ? this.baseUrl.replace(/\/+$/, '') : this.baseUrl;
if (normalizedBase === '/api') {
healthUrl = '/health';
} else if (normalizedBase.endsWith('/api')) {
// Desktop: http://127.0.0.1:PORT/api -> http://127.0.0.1:PORT/health
healthUrl = `${normalizedBase.slice(0, -4)}/health`;
} else {
healthUrl = `${normalizedBase}/health`;
}
const response = await fetch(healthUrl);
if (!response.ok) {
return false;
}
const healthData = await response.json();
// Check if the upstream API is ready (not just OpenChamber server)
if (healthData.isOpenCodeReady === false) {
return false;
}
return 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 fetch(`${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 listLocalDirectory(directoryPath: string | null | undefined, options?: { respectGitignore?: boolean }): Promise<FilesystemEntry[]> {
const desktopFiles = getDesktopFilesApi();
if (desktopFiles) {
try {
const result = await desktopFiles.listDirectory(directoryPath || '', options);
if (!result || !Array.isArray(result.entries)) {
return [];
}
return result.entries.map<FilesystemEntry>((entry) => ({
name: entry.name,
path: normalizeFsPath(entry.path),
isDirectory: !!entry.isDirectory,
isFile: !entry.isDirectory,
isSymbolicLink: false,
}));
} 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 fetch(`${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 [];
}
return result.entries as FilesystemEntry[];
} catch (error) {
console.error('Failed to list directory contents:', error);
throw error;
}
}
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> {
// Optimization: Check for desktop runtime first to avoid unnecessary network calls
// and fix the "SyntaxError" warning when the endpoint is missing
const desktopHome = await getDesktopHomeDirectory();
if (desktopHome) {
return desktopHome;
}
try {
const response = await fetch(`${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 fetch(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 };