When the caller passes its own AbortSignal and AbortSignal.any is unavailable, the timeout was silently dropped — disabling the fix on exactly the bootstrap reads it targets, since those carry a cancellation signal. Compose the two signals manually through an AbortController in that case; listeners detach when the request settles.
2098 lines
74 KiB
TypeScript
2098 lines
74 KiB
TypeScript
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
|
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
|
|
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
|
|
import type { FilesAPI } from "../api/types";
|
|
import { getDesktopHomeDirectory } from "../desktop";
|
|
import type {
|
|
Session,
|
|
Message,
|
|
Part,
|
|
Provider,
|
|
Config,
|
|
Agent,
|
|
TextPartInput,
|
|
FilePartInput,
|
|
} from "@opencode-ai/sdk/v2";
|
|
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
|
|
import { FilesystemError, parseFilesystemErrorReason } from "@/lib/api/files-errors";
|
|
import type { PermissionRequest } from "@/types/permission";
|
|
import type { QuestionRequest } from "@/types/question";
|
|
|
|
/**
|
|
* Tagged result of `OpencodeService.fetchPermission()`. The caller can
|
|
* distinguish a server-confirmed "no longer pending" permission (HTTP
|
|
* 404) from a fetch failure (network error, malformed response, or a
|
|
* pre-v1.17.12 server without the V2 endpoint).
|
|
*/
|
|
export type FetchPermissionResult =
|
|
| { state: "ok"; permission: PermissionV2Request }
|
|
| { state: "resolved" }
|
|
| { state: "unknown" };
|
|
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,
|
|
} 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";
|
|
const CONFIG_CACHE_TTL_MS = 10_000;
|
|
const OPENCODE_HEALTH_TIMEOUT_MS = 4_000;
|
|
|
|
/**
|
|
* 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 };
|
|
};
|
|
|
|
type DirectoryAvailability = "available" | "missing" | "unknown";
|
|
|
|
const isMissingDirectoryError = (error: unknown): boolean => {
|
|
if (error instanceof FilesystemError) {
|
|
return error.reason === "not-found" || error.reason === "not-directory";
|
|
}
|
|
if (error && typeof error === "object") {
|
|
const code = (error as { code?: unknown }).code;
|
|
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
return true;
|
|
}
|
|
}
|
|
return /\bENOENT\b|\bENOTDIR\b|no such file or directory/i.test(formatSdkError(error));
|
|
};
|
|
|
|
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 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;
|
|
}
|
|
};
|
|
|
|
type AbortSignalConstructorWithTimeout = typeof AbortSignal & {
|
|
timeout?: (milliseconds: number) => AbortSignal;
|
|
};
|
|
|
|
const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: () => void } => {
|
|
const abortSignal = typeof AbortSignal !== 'undefined'
|
|
? AbortSignal as AbortSignalConstructorWithTimeout
|
|
: undefined;
|
|
if (typeof abortSignal?.timeout === 'function') {
|
|
return { signal: abortSignal.timeout(timeoutMs), cleanup: () => undefined };
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
return {
|
|
signal: controller.signal,
|
|
cleanup: () => clearTimeout(timeoutId),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Upper bound for non-streaming OpenCode read requests. Without it, a socket
|
|
* that neither resolves nor rejects (the half-open state described in #2470)
|
|
* keeps the bootstrap concurrency slot busy forever and the UI stays on
|
|
* "loading sessions". Long-lived streams (POST prompts, the /event SSE) are
|
|
* explicitly excluded in {@link createRuntimeOpencodeClient}.
|
|
*/
|
|
const OPENCODE_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
|
const isEventStreamUrl = (input: string | URL | Request): boolean => {
|
|
const url = typeof input === 'string'
|
|
? input
|
|
: input instanceof URL
|
|
? input.toString()
|
|
: input.url;
|
|
return url.includes('/event');
|
|
};
|
|
|
|
type RuntimeOpencodeClientConfig = {
|
|
baseUrl: string;
|
|
directory?: string;
|
|
/** Read-request timeout in ms. Overridable so tests can use short value. */
|
|
requestTimeoutMs?: number;
|
|
};
|
|
|
|
export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => {
|
|
const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS;
|
|
return createOpencodeClient({
|
|
...config,
|
|
fetch: async (input: string | URL | Request, init?: RequestInit) => {
|
|
const method = String(
|
|
init?.method ?? (input instanceof Request ? input.method : 'GET'),
|
|
).toUpperCase();
|
|
if (isEventStreamUrl(input) || method === 'POST') {
|
|
return runtimeFetch(input, init);
|
|
}
|
|
const timeout = createTimeoutSignal(requestTimeoutMs);
|
|
const callerSignal = init?.signal;
|
|
const supportsAny = typeof AbortSignal !== 'undefined'
|
|
&& typeof (AbortSignal as { any?: unknown }).any === 'function';
|
|
let signal: AbortSignal;
|
|
let detachFallback: (() => void) | null = null;
|
|
if (callerSignal && supportsAny) {
|
|
signal = (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal })
|
|
.any([callerSignal, timeout.signal]);
|
|
} else if (callerSignal) {
|
|
// No AbortSignal.any: compose manually. Silently dropping the timeout
|
|
// here would disable the fix on exactly the bootstrap reads it
|
|
// targets, since those carry a cancellation signal.
|
|
const controller = new AbortController();
|
|
const abortFromCaller = () => controller.abort(callerSignal.reason);
|
|
const abortFromTimeout = () => controller.abort(timeout.signal.reason);
|
|
if (callerSignal.aborted) {
|
|
abortFromCaller();
|
|
} else if (timeout.signal.aborted) {
|
|
abortFromTimeout();
|
|
} else {
|
|
callerSignal.addEventListener('abort', abortFromCaller, { once: true });
|
|
timeout.signal.addEventListener('abort', abortFromTimeout, { once: true });
|
|
detachFallback = () => {
|
|
callerSignal.removeEventListener('abort', abortFromCaller);
|
|
timeout.signal.removeEventListener('abort', abortFromTimeout);
|
|
};
|
|
}
|
|
signal = controller.signal;
|
|
} else {
|
|
signal = timeout.signal;
|
|
}
|
|
try {
|
|
return await runtimeFetch(input, { ...init, signal });
|
|
} catch (error) {
|
|
if (timeout.signal.aborted && !callerSignal?.aborted) {
|
|
throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
detachFallback?.();
|
|
timeout.cleanup();
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
interface App {
|
|
version?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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 configProvidersInFlight: Map<string, Promise<{ providers: Provider[]; default: { [key: string]: string } }>> = new Map();
|
|
private listAgentsInFlight: Map<string, Promise<Agent[]>> = new Map();
|
|
private configInFlight: Map<string, Promise<Config>> = new Map();
|
|
private configCache: Map<string, { config: Config; expiresAt: number }> = new Map();
|
|
private configCacheGeneration = 0;
|
|
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 });
|
|
}
|
|
|
|
private assertRuntimeUnchanged(runtimeKey?: string): void {
|
|
if (runtimeKey && runtimeKey !== getRuntimeKey()) {
|
|
throw new Error('Message was not sent because the runtime changed.');
|
|
}
|
|
}
|
|
|
|
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.configProvidersInFlight.clear();
|
|
this.listAgentsInFlight.clear();
|
|
this.clearConfigCache();
|
|
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> {
|
|
return (await this.getDirectoryAvailability(directory)) === "available";
|
|
}
|
|
|
|
/**
|
|
* Distinguishes a confirmed-missing directory from an unavailable probe.
|
|
* Offline, permission, and other transport failures stay `unknown` so callers
|
|
* do not treat a temporary outage as proof the path was deleted.
|
|
*/
|
|
async getDirectoryAvailability(directory: string): Promise<DirectoryAvailability> {
|
|
const normalized = this.normalizeCandidatePath(directory);
|
|
if (!normalized) {
|
|
return "unknown";
|
|
}
|
|
try {
|
|
const response = await this.client.path.get({ directory: normalized }) as SdkResult<{ directory?: unknown }>;
|
|
if (response.error) {
|
|
return isMissingDirectoryError(response.error) ? "missing" : "unknown";
|
|
}
|
|
const returned = typeof response.data?.directory === "string" ? response.data.directory.trim() : "";
|
|
return returned ? "available" : "unknown";
|
|
} catch (error) {
|
|
return isMissingDirectoryError(error) ? "missing" : "unknown";
|
|
}
|
|
}
|
|
|
|
// 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, directory?: string | null): Promise<{ info: Message; parts: Part[] }[]> {
|
|
const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory;
|
|
const response = await this.client.session.messages({
|
|
sessionID: id,
|
|
...(requestDirectory ? { directory: requestDirectory } : {}),
|
|
...(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: {
|
|
runtimeKey?: string;
|
|
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;
|
|
metadata?: ContextPartMetadata;
|
|
files?: Array<FileInputLite>;
|
|
}>;
|
|
messageId?: string;
|
|
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
|
delivery?: 'steer';
|
|
format?: {
|
|
type: 'json_schema';
|
|
schema: Record<string, unknown>;
|
|
retryCount?: number;
|
|
};
|
|
directory?: string | null;
|
|
}): Promise<string> {
|
|
this.assertRuntimeUnchanged(params.runtimeKey);
|
|
|
|
// Use the optimistic/client-generated ID as the real user message ID so SSE
|
|
// can reconcile the echoed server message in-place.
|
|
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()) {
|
|
const additionalTextPart: TextPartInput = { type: 'text', text: additional.text };
|
|
if (additional.synthetic) additionalTextPart.synthetic = true;
|
|
if (additional.metadata) additionalTextPart.metadata = additional.metadata;
|
|
parts.push(additionalTextPart);
|
|
}
|
|
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);
|
|
this.assertRuntimeUnchanged(params.runtimeKey);
|
|
|
|
let response: Response;
|
|
|
|
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.delivery ? { delivery: params.delivery } : {}),
|
|
...(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;
|
|
if (!status) {
|
|
// The SDK caught a thrown fetch error (network/tunnel transport
|
|
// failure) — there is no HTTP response to report. Never fabricate a
|
|
// status: surface it as a transport error so callers treat it like
|
|
// any other network failure instead of a server 500.
|
|
// Preserve the transport's "dispatched, outcome unknown" tag through
|
|
// the wrap: without it the caller cannot tell a lost response from a
|
|
// send that never reached the server, and re-sends a running prompt.
|
|
const transportError = new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
|
|
throw isAmbiguousTransportFailure(result.error)
|
|
? markAmbiguousTransportFailure(transportError)
|
|
: transportError;
|
|
}
|
|
response = new Response(JSON.stringify(result.error), { status });
|
|
} else {
|
|
response = new Response(JSON.stringify(result.data ?? true), { status: 200 });
|
|
}
|
|
} catch (error) {
|
|
// Do not retry prompt_async after a transport failure: through a remote
|
|
// tunnel the POST may already be running server-side even though the
|
|
// client lost the response.
|
|
recordProviderError(params.providerID);
|
|
throw error;
|
|
}
|
|
|
|
if (response.ok) {
|
|
recordProviderSuccess(params.providerID);
|
|
return messageId;
|
|
}
|
|
|
|
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}`) as Error & { status?: number };
|
|
error.status = response.status;
|
|
recordProviderError(params.providerID, response.status);
|
|
throw error;
|
|
}
|
|
|
|
async sendCommand(params: {
|
|
runtimeKey?: string;
|
|
id: string;
|
|
providerID: string;
|
|
modelID: string;
|
|
command: string;
|
|
arguments?: string;
|
|
agent?: string;
|
|
variant?: string;
|
|
files?: Array<FileInputLite>;
|
|
messageId?: string;
|
|
directory?: string | null;
|
|
}): Promise<string> {
|
|
this.assertRuntimeUnchanged(params.runtimeKey);
|
|
|
|
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;
|
|
this.assertRuntimeUnchanged(params.runtimeKey);
|
|
|
|
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: {
|
|
runtimeKey?: string;
|
|
sessionId: string;
|
|
command: string;
|
|
agent: string;
|
|
model: { providerID: string; modelID: string };
|
|
messageId?: string;
|
|
directory?: string | null;
|
|
}): Promise<{ info: Message; parts: Part[] }> {
|
|
this.assertRuntimeUnchanged(params.runtimeKey);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Programmatically evaluate and (when approval is required) create a
|
|
* permission request for a session via the V2 endpoint introduced in
|
|
* OpenCode SDK v1.17.12. Wraps `session.permission.create`.
|
|
*
|
|
* Returns `{ id, effect }` on success, or `null` on any failure
|
|
* (network error, 4xx/5xx response, malformed payload, or pre-v1.17.12
|
|
* server without the V2 endpoint). Callers driving authoritative state
|
|
* must treat `null` as "unknown — do not act" rather than "permission
|
|
* allowed."
|
|
*
|
|
* Thin wrapper for future programmatic permission creation. The V1
|
|
* `permission.list` / `permission.reply` flow used by the auto-accept
|
|
* path is unchanged.
|
|
*/
|
|
async createPermission(
|
|
sessionID: string,
|
|
action: string,
|
|
resources: string[],
|
|
options?: {
|
|
id?: string;
|
|
save?: string[];
|
|
metadata?: ContextPartMetadata;
|
|
source?: PermissionV2Source;
|
|
agent?: string;
|
|
}
|
|
): Promise<{ id: string; effect: PermissionV2Effect } | null> {
|
|
try {
|
|
const response = await this.client.v2.session.permission.create({
|
|
sessionID,
|
|
action,
|
|
resources,
|
|
...(options?.id ? { id: options.id } : {}),
|
|
...(options?.save ? { save: options.save } : {}),
|
|
...(options?.metadata ? { metadata: options.metadata } : {}),
|
|
...(options?.source ? { source: options.source } : {}),
|
|
...(options?.agent ? { agent: options.agent } : {}),
|
|
});
|
|
// Discriminated union narrowing on `error` (see fetchPermission).
|
|
if (response.error !== undefined) return null;
|
|
const payload = response.data?.data;
|
|
if (payload === undefined) return null;
|
|
return { id: payload.id, effect: payload.effect };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a pending permission request owned by a session via the V2
|
|
* endpoint introduced in OpenCode SDK v1.17.12. Wraps
|
|
* `session.permission.get`.
|
|
*
|
|
* Returns a tagged `FetchPermissionResult` so the caller can distinguish
|
|
* a confirmed-resolved permission (HTTP 404) from a fetch failure
|
|
* (network error, malformed response, or pre-v1.17.12 server without
|
|
* the V2 endpoint). The auto-accept flow uses this distinction to drop
|
|
* resolved permissions from the resync output, preventing stale
|
|
* `permission.list` entries from sticking around in the UI.
|
|
*/
|
|
async fetchPermission(
|
|
sessionID: string,
|
|
requestID: string,
|
|
directory?: string,
|
|
): Promise<FetchPermissionResult> {
|
|
try {
|
|
// The V2 endpoint does not accept a directory parameter. Callers that
|
|
// reconcile a known project must therefore select its scoped SDK client.
|
|
const client = directory ? this.getScopedSdkClient(directory) : this.client;
|
|
const response = await client.v2.session.permission.get({
|
|
sessionID,
|
|
requestID,
|
|
});
|
|
// The SDK returns a discriminated union on `error`/`data` (HeyApi
|
|
// `RequestResult` with `ThrowOnError = false`). The error branch
|
|
// collapses `data` to `undefined`; the data branch returns the
|
|
// 200-response payload as `{ data: PermissionV2Request }`. Narrow
|
|
// via `error` first, then unwrap the inner `data` field.
|
|
if (response.error === undefined) {
|
|
const payload = response.data?.data;
|
|
if (payload !== undefined) {
|
|
return { state: "ok", permission: payload };
|
|
}
|
|
}
|
|
// On the error branch the server has answered but the request was
|
|
// not found. V2SessionPermissionGetErrors maps 404 to
|
|
// `PermissionNotFoundError`, so the only server-confirmed
|
|
// "no longer pending" signal we have is HTTP 404.
|
|
if (response.response?.status === 404) {
|
|
return { state: "resolved" };
|
|
}
|
|
return { state: "unknown" };
|
|
} catch {
|
|
// Network failure, pre-v1.17.12 server, or runtimeFetch throwing.
|
|
// Treat as "unknown" — caller must decide what to do (auto-accept
|
|
// fails closed, but the permission stays in the resync output so
|
|
// the user can still act on it).
|
|
return { state: "unknown" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
clearConfigCache(): void {
|
|
this.configCacheGeneration += 1;
|
|
this.configInFlight.clear();
|
|
this.configCache.clear();
|
|
}
|
|
|
|
async getConfig(directory?: string | null): Promise<Config> {
|
|
const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined;
|
|
const key = effectiveDirectory ?? '';
|
|
const cached = this.configCache.get(key);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
markStartupTrace('opencodeClient.getConfig:cacheHit', { directory: effectiveDirectory ?? null });
|
|
return cached.config;
|
|
}
|
|
|
|
const existing = this.configInFlight.get(key);
|
|
if (existing) {
|
|
markStartupTrace('opencodeClient.getConfig:deduped', { directory: effectiveDirectory ?? null });
|
|
return existing;
|
|
}
|
|
|
|
const generation = this.configCacheGeneration;
|
|
const request = (async () => {
|
|
markStartupTrace('opencodeClient.getConfig:start', { directory: effectiveDirectory ?? null });
|
|
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const scopedClient = effectiveDirectory ? this.getScopedApiClient(effectiveDirectory) : this.client;
|
|
const response = await scopedClient.config.get();
|
|
if (!response.data) throw new Error('Failed to get config');
|
|
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
markStartupTrace('opencodeClient.getConfig:end', {
|
|
directory: effectiveDirectory ?? null,
|
|
durationMs: Math.round(ended - started),
|
|
});
|
|
if (generation === this.configCacheGeneration) {
|
|
this.configCache.set(key, { config: response.data, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS });
|
|
}
|
|
return response.data;
|
|
})();
|
|
|
|
this.configInFlight.set(key, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
if (this.configInFlight.get(key) === request) {
|
|
this.configInFlight.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 });
|
|
const data = unwrapSdkData(response, 'global.config.update');
|
|
this.clearConfigCache();
|
|
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 };
|
|
}> {
|
|
return this.getProvidersForConfig(this.currentDirectory);
|
|
}
|
|
|
|
async getProvidersForConfig(directory?: string | null): Promise<{
|
|
providers: Provider[];
|
|
default: { [key: string]: string };
|
|
}> {
|
|
const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined;
|
|
const key = effectiveDirectory ?? '';
|
|
|
|
const existing = this.configProvidersInFlight.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const request = (async () => {
|
|
const response = await this.client.config.providers(
|
|
effectiveDirectory ? { directory: effectiveDirectory } : undefined,
|
|
);
|
|
return unwrapSdkData(response, 'config.providers');
|
|
})();
|
|
|
|
this.configProvidersInFlight.set(key, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
this.configProvidersInFlight.delete(key);
|
|
}
|
|
}
|
|
|
|
// 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(directory?: string | null): Promise<Agent[]> {
|
|
// Pass the directory explicitly so we don't depend on (and serialize behind)
|
|
// withDirectory's shared context queue. Concurrent callers for the same
|
|
// directory (e.g. config store + agents store at startup) share one request.
|
|
const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined;
|
|
const key = effectiveDirectory ?? '';
|
|
|
|
const existing = this.listAgentsInFlight.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const request = (async () => {
|
|
const params = effectiveDirectory ? { directory: effectiveDirectory } : undefined;
|
|
const response = await this.client.app.agents(params);
|
|
if (!response.error && Array.isArray(response.data) && response.data.length > 0) {
|
|
return response.data;
|
|
}
|
|
|
|
// SDK gap / endpoint drift: current OpenCode exposes the authoritative
|
|
// agent list at /agent, while app.agents can be empty on some runtimes.
|
|
const fallbackResponse = await runtimeFetch('/api/agent', {
|
|
...(effectiveDirectory ? { query: { directory: effectiveDirectory } } : {}),
|
|
});
|
|
if (!fallbackResponse.ok) {
|
|
if (response.error) {
|
|
throw new Error(`app.agents failed${response.response?.status ? ` (${response.response.status})` : ''}: ${formatSdkError(response.error)}`);
|
|
}
|
|
throw new Error(`agent.list failed (${fallbackResponse.status})`);
|
|
}
|
|
|
|
const fallbackData = await fallbackResponse.json().catch(() => null) as unknown;
|
|
if (!Array.isArray(fallbackData)) {
|
|
throw new Error('agent.list failed: invalid response');
|
|
}
|
|
return fallbackData as Agent[];
|
|
})();
|
|
|
|
this.listAgentsInFlight.set(key, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
this.listAgentsInFlight.delete(key);
|
|
}
|
|
}
|
|
|
|
// 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(directory?: string | null): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string; template?: string }>> {
|
|
const requestDirectory = this.normalizeCandidatePath(directory ?? null) ?? this.currentDirectory;
|
|
const response = await this.client.command.list(
|
|
requestDirectory ? { directory: requestDirectory } : 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 timeout = createTimeoutSignal(OPENCODE_HEALTH_TIMEOUT_MS);
|
|
const response = await runtimeFetch(healthUrl, { signal: timeout.signal }).finally(timeout.cleanup);
|
|
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; asProject?: 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');
|
|
}
|
|
}
|
|
|
|
if (options?.asProject) {
|
|
const response = await runtimeFetch(`${this.baseUrl}/opencode/directory`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ path: dirPath, create: true }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: 'Failed to create project directory' }));
|
|
throw new Error(error.error || 'Failed to create project directory');
|
|
}
|
|
|
|
const result = await response.json();
|
|
return { success: true, path: result.path };
|
|
}
|
|
|
|
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();
|
|
try {
|
|
if (desktopFiles) {
|
|
const result = await desktopFiles.listDirectory(directoryPath || '', options);
|
|
if (!result || !Array.isArray(result.entries)) {
|
|
throw new FilesystemError('Directory listing returned an invalid response', {
|
|
reason: 'invalid-response',
|
|
});
|
|
}
|
|
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;
|
|
}
|
|
|
|
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 FilesystemError(message, {
|
|
reason: parseFilesystemErrorReason((error as { reason?: unknown }).reason),
|
|
status: response.status,
|
|
});
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (!result || !Array.isArray(result.entries)) {
|
|
throw new FilesystemError('Directory listing returned an invalid response', {
|
|
reason: 'invalid-response',
|
|
});
|
|
}
|
|
|
|
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
|