feat: add multi-project support (#110)
* feat: Implement project management store with project path validation and synchronization - Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths. - Implemented persistence for projects and active project ID using safe storage. - Introduced synchronization from desktop settings to keep project data consistent. - Enhanced session store to manage sessions by directory and added new methods for session management. - Updated todo store to fetch session todos based on the directory context. - Refactored server code to validate and resolve project directories for various API endpoints. - Added project entry validation and sanitization to ensure data integrity. * feat(settings): migrate legacy project settings and update settings loading logic * feat: enhance project management with directory-aware settings and improved agent/command source handling * feat: enhance session and project management with directory-aware settings and improved configuration refresh logic * feat: enhance project management with worktree manager integration and project directory resolution * feat: enhance agent groups store with project directory resolution and loading logic * feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers * feat: refactor command and project handling in useCommandsStore - Replaced useDirectoryStore with useProjectsStore to manage project paths. - Introduced getRequestDirectory function to determine the active project directory. - Updated command fetching to respect project-level scoping. - Enhanced error handling and logging for command configuration fetching. - Improved command configuration saving and updating to utilize project directory context. feat: enhance project path normalization in useProjectsStore - Added resolveTildePath function to expand paths starting with ~. - Updated normalizeProjectPath to utilize home directory for path expansion. fix: update permission handling in useSessionStore - Changed Permission type to PermissionRequest for clarity. - Updated respondToPermission method to use requestId instead of permissionId. refactor: improve permission utilities - Introduced types for PermissionAction and PermissionRule. - Enhanced getAgentDefinition and resolveConfigStore functions for better type safety. - Added resolvePermissionAction to streamline permission resolution logic. feat: add agent configuration retrieval endpoint - Implemented new API endpoint to fetch agent configuration based on project directory. - Enhanced getAgentPermissionSource to prioritize project-level permissions. chore: update SDK version in package.json files - Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files. refactor: streamline bridge message handling - Updated handleBridgeMessage to accept directory parameter for agent and command requests. - Improved local API request handling to extract directory from query parameters and headers. feat: enhance project configuration management - Added functions to retrieve and merge project configuration paths. - Improved handling of existing project configuration files for agents and commands. * feat: enhance VSCode integration and session management - Added support for a sticky sidebar header background in light and dark themes. - Introduced functions to read VSCode workspace directory and check if running in VSCode. - Implemented detailed logging for session loading and creation processes. - Enhanced session filtering based on directory structure and canonical paths. - Added a new method to reorder projects and prevent modifications in VSCode workspace. - Improved error handling and logging for app initialization and markdown file parsing. - Updated API checks and health checks to ensure readiness before proceeding. - Refactored code for better readability and maintainability across various modules. * feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response * feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options * fix(ui): share IME guard and cover multi-run * fix(session): reduce maximum visible sessions in group from 7 to 5
This commit is contained in:
committed by
GitHub
parent
8aa379e313
commit
18c5b4c7b5
@@ -13,6 +13,7 @@ import type {
|
||||
FilePartInput,
|
||||
Event,
|
||||
} from "@opencode-ai/sdk/v2";
|
||||
import type { PermissionRequest } from "@/types/permission";
|
||||
type StreamEvent<TData> = {
|
||||
data: TData;
|
||||
event?: string;
|
||||
@@ -20,6 +21,11 @@ type StreamEvent<TData> = {
|
||||
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";
|
||||
@@ -132,6 +138,14 @@ class OpencodeService {
|
||||
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||
private currentDirectory: string | undefined = undefined;
|
||||
|
||||
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();
|
||||
|
||||
constructor(baseUrl: string = DEFAULT_BASE_URL) {
|
||||
const desktopBase = resolveDesktopBaseUrl();
|
||||
const requestedBaseUrl = desktopBase || baseUrl;
|
||||
@@ -726,21 +740,46 @@ class OpencodeService {
|
||||
return this.getSessionStatusForDirectory(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 respondToPermission(
|
||||
sessionId: string,
|
||||
permissionId: string,
|
||||
response: 'once' | 'always' | 'reject'
|
||||
async replyToPermission(
|
||||
requestId: string,
|
||||
reply: 'once' | 'always' | 'reject',
|
||||
options?: { message?: string }
|
||||
): Promise<boolean> {
|
||||
const result = await this.client.permission.respond({
|
||||
sessionID: sessionId,
|
||||
permissionID: permissionId,
|
||||
const result = await this.client.permission.reply({
|
||||
requestID: requestId,
|
||||
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
|
||||
response
|
||||
reply,
|
||||
...(options?.message ? { message: options.message } : {}),
|
||||
});
|
||||
return result.data || false;
|
||||
}
|
||||
|
||||
async listPendingPermissions(): Promise<PermissionRequest[]> {
|
||||
try {
|
||||
// Permission requests are global across sessions; do not scope by directory.
|
||||
const result = await this.client.permission.list();
|
||||
return (result.data || []) as unknown as PermissionRequest[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async getConfig(): Promise<Config> {
|
||||
const response = await this.client.config.get();
|
||||
@@ -773,7 +812,7 @@ class OpencodeService {
|
||||
|
||||
/**
|
||||
* Update config with a partial modification function.
|
||||
* This handles the GET-modify-PATCH pattern required by OpenCode API.
|
||||
* 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.
|
||||
@@ -830,6 +869,286 @@ class OpencodeService {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const listener of this.globalSseListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.warn('[OpencodeClient] Global SSE listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -839,6 +1158,7 @@ class OpencodeService {
|
||||
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();
|
||||
@@ -850,17 +1170,122 @@ class OpencodeService {
|
||||
|
||||
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 =
|
||||
options?.scope === 'global'
|
||||
? undefined
|
||||
: typeof directoryOverride === 'string' && directoryOverride.trim().length > 0
|
||||
? directoryOverride.trim()
|
||||
: this.currentDirectory;
|
||||
console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'global');
|
||||
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 {
|
||||
@@ -891,8 +1316,9 @@ class OpencodeService {
|
||||
lastEventId = event.id;
|
||||
}
|
||||
const payload = event.data;
|
||||
if (payload && typeof payload === 'object') {
|
||||
onMessage(payload as Event);
|
||||
const normalized = normalizeEventPayload(payload);
|
||||
if (normalized) {
|
||||
onMessage(normalized);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -915,13 +1341,6 @@ class OpencodeService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
// Stream ended unexpectedly; attempt reconnect
|
||||
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await connect(attempt + 1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||
console.log('[OpencodeClient] SSE stream aborted normally');
|
||||
@@ -936,6 +1355,13 @@ class OpencodeService {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -956,6 +1382,7 @@ class OpencodeService {
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// File Operations
|
||||
@@ -1092,7 +1519,7 @@ class OpencodeService {
|
||||
|
||||
const healthData = await response.json();
|
||||
|
||||
// Check if OpenCode is actually ready (not just OpenChamber server)
|
||||
// Check if the upstream API is ready (not just OpenChamber server)
|
||||
if (healthData.isOpenCodeReady === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user