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
@@ -15,6 +15,7 @@ export class AgentManagerPanelProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
@@ -58,6 +59,12 @@ export class AgentManagerPanelProvider {
|
||||
controller.abort();
|
||||
}
|
||||
this._sseStreams.clear();
|
||||
|
||||
for (const heartbeat of this._sseHeartbeats.values()) {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
this._sseHeartbeats.clear();
|
||||
|
||||
this._panel = undefined;
|
||||
}, null, this._context.subscriptions);
|
||||
|
||||
@@ -162,12 +169,29 @@ export class AgentManagerPanelProvider {
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: this._buildSseHeaders(headers || {}),
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Fallback for OpenCode versions without /global/event.
|
||||
if ((!response.ok || !response.body) && normalizedPath === '/global/event') {
|
||||
const fallbackUrl = new URL('event', base).toString();
|
||||
response = await fetch(fallbackUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok && response.body) {
|
||||
wrapAsGlobal = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -196,6 +220,21 @@ export class AgentManagerPanelProvider {
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|| 'global';
|
||||
|
||||
if (shouldInjectActivity) {
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||
}, 30000);
|
||||
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
@@ -216,7 +255,10 @@ export class AgentManagerPanelProvider {
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
||||
const routedBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||
: blocks;
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
@@ -229,7 +271,10 @@ export class AgentManagerPanelProvider {
|
||||
}
|
||||
if (sseBuffer) {
|
||||
if (shouldInjectActivity) {
|
||||
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
||||
const baseBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||
: [sseBuffer];
|
||||
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._panel?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
} else {
|
||||
@@ -252,6 +297,11 @@ export class AgentManagerPanelProvider {
|
||||
}
|
||||
} finally {
|
||||
this._sseStreams.delete(streamId);
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -276,6 +326,12 @@ export class AgentManagerPanelProvider {
|
||||
controller.abort();
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
@@ -396,6 +452,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||
})}`;
|
||||
};
|
||||
|
||||
const buildHeartbeatEventBlock = (): string => {
|
||||
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||
};
|
||||
|
||||
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | 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/, ''));
|
||||
continue;
|
||||
}
|
||||
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 parsed = JSON.parse(payloadText) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||
? (nestedPayload as Record<string, unknown>)
|
||||
: record;
|
||||
|
||||
return eventId ? { id: eventId, payload } : { payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim().replace(/\\/g, '/')
|
||||
: 'global';
|
||||
|
||||
return blocks.map((block) => {
|
||||
const parsed = parseSseBlockForGlobalWrap(block);
|
||||
if (!parsed) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
directory: normalizedDirectory,
|
||||
payload: parsed.payload,
|
||||
};
|
||||
|
||||
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||
});
|
||||
};
|
||||
|
||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||
const expanded: string[] = [];
|
||||
for (const block of blocks) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private _sseHeartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
@@ -195,12 +196,30 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
let wrapAsGlobal = false;
|
||||
|
||||
const requestHeaders = this._buildSseHeaders(headers || {});
|
||||
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: this._buildSseHeaders(headers || {}),
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Fallback: OpenCode versions without /global/event.
|
||||
// VS Code is single-workspace, so we can wrap /event into { directory, payload }.
|
||||
if ((!response.ok || !response.body) && normalizedPath === '/global/event') {
|
||||
const fallbackUrl = new URL('event', base).toString();
|
||||
response = await fetch(fallbackUrl, {
|
||||
method: 'GET',
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok && response.body) {
|
||||
wrapAsGlobal = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -229,6 +248,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
const fallbackDirectory = this._openCodeManager?.getWorkingDirectory()
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|| 'global';
|
||||
|
||||
if (shouldInjectActivity) {
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const heartbeatChunk = `${buildHeartbeatEventBlock()}\n\n`;
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: heartbeatChunk });
|
||||
}, 30000);
|
||||
this._sseHeartbeats.set(streamId, heartbeatTimer);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
@@ -251,7 +285,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
|
||||
const routedBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal(blocks, fallbackDirectory)
|
||||
: blocks;
|
||||
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(routedBlocks) : routedBlocks;
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
@@ -264,7 +301,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
if (sseBuffer) {
|
||||
if (shouldInjectActivity) {
|
||||
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
|
||||
const baseBlocks = wrapAsGlobal
|
||||
? wrapSseBlocksAsGlobal([sseBuffer], fallbackDirectory)
|
||||
: [sseBuffer];
|
||||
const outboundBlocks = expandSseBlocksWithActivity(baseBlocks);
|
||||
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
} else {
|
||||
@@ -287,6 +327,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
} finally {
|
||||
this._sseStreams.delete(streamId);
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -311,6 +356,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
controller.abort();
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
|
||||
const heartbeat = this._sseHeartbeats.get(streamId);
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
this._sseHeartbeats.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
@@ -432,6 +483,80 @@ const buildActivityEventBlock = (activity: SessionActivity): string => {
|
||||
})}`;
|
||||
};
|
||||
|
||||
const buildHeartbeatEventBlock = (): string => {
|
||||
return `data: ${JSON.stringify({ type: 'openchamber:heartbeat', timestamp: Date.now() })}`;
|
||||
};
|
||||
|
||||
const parseSseBlockForGlobalWrap = (block: string): { id?: string; payload: Record<string, unknown> } | 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/, ''));
|
||||
continue;
|
||||
}
|
||||
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 parsed = JSON.parse(payloadText) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const nestedPayload = record.payload;
|
||||
const payload = nestedPayload && typeof nestedPayload === 'object'
|
||||
? (nestedPayload as Record<string, unknown>)
|
||||
: record;
|
||||
|
||||
return eventId ? { id: eventId, payload } : { payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrapSseBlocksAsGlobal = (blocks: string[], directory: string): string[] => {
|
||||
const normalizedDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim().replace(/\\/g, '/')
|
||||
: 'global';
|
||||
|
||||
return blocks.map((block) => {
|
||||
const parsed = parseSseBlockForGlobalWrap(block);
|
||||
if (!parsed) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
directory: normalizedDirectory,
|
||||
payload: parsed.payload,
|
||||
};
|
||||
|
||||
const idPrefix = parsed.id ? `id: ${parsed.id}\n` : '';
|
||||
return `${idPrefix}data: ${JSON.stringify(envelope)}`;
|
||||
});
|
||||
};
|
||||
|
||||
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
|
||||
const expanded: string[] = [];
|
||||
for (const block of blocks) {
|
||||
|
||||
@@ -618,23 +618,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
|
||||
case 'api:config/agents': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
|
||||
const agentName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!agentName) {
|
||||
return { id, type, success: false, error: 'Agent name is required' };
|
||||
}
|
||||
|
||||
// Get working directory for project-level agent support
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
// Use directory from request if provided, otherwise fall back to workspace
|
||||
const workingDirectory = (typeof directory === 'string' && directory.trim())
|
||||
? directory.trim()
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath);
|
||||
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getAgentSources(agentName, workingDirectory);
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: agentName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -693,23 +698,28 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
|
||||
case 'api:config/commands': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown>; directory?: string };
|
||||
const commandName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!commandName) {
|
||||
return { id, type, success: false, error: 'Command name is required' };
|
||||
}
|
||||
|
||||
// Get working directory for project-level command support
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
// Use directory from request if provided, otherwise fall back to workspace
|
||||
const workingDirectory = (typeof directory === 'string' && directory.trim())
|
||||
? directory.trim()
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath);
|
||||
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getCommandSources(commandName, workingDirectory);
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: commandName, sources, scope: sources.md.scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -305,9 +305,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
const isJson = contentType.toLowerCase().includes('json') && !contentType.toLowerCase().includes('text/html');
|
||||
|
||||
let summary = '';
|
||||
if (contentType.includes('application/json')) {
|
||||
if (isJson) {
|
||||
const json = await resp.json().catch(() => null);
|
||||
if (Array.isArray(json)) {
|
||||
summary = `json[array] len=${json.length}`;
|
||||
@@ -321,7 +322,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
summary = contentType ? `content-type=${contentType}` : 'no content-type';
|
||||
}
|
||||
|
||||
return { ok: resp.ok, status: resp.status, elapsedMs, summary };
|
||||
return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary };
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const isAbort =
|
||||
@@ -356,6 +357,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
{ label: 'commands', path: '/command', includeDirectory: true },
|
||||
{ label: 'project', path: '/project/current', includeDirectory: true },
|
||||
{ label: 'path', path: '/path', includeDirectory: true },
|
||||
// Session listing is what powers the sidebar. This helps diagnose "no sessions shown" bugs.
|
||||
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 8000 },
|
||||
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
||||
];
|
||||
|
||||
|
||||
+195
-140
@@ -1,15 +1,16 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Optimized timeouts for faster startup
|
||||
const PORT_DETECTION_TIMEOUT_MS = 10000;
|
||||
const READY_CHECK_TIMEOUT_MS = 12000;
|
||||
const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup
|
||||
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||
const READY_CHECK_INTERVAL_MS = 250; // Avoid hammering the server during startup
|
||||
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
||||
const SHUTDOWN_TIMEOUT_MS = 3000;
|
||||
const DEFAULT_OPENCODE_PORT = 4096;
|
||||
|
||||
// Regex to detect port from CLI output (matches desktop pattern)
|
||||
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
||||
@@ -65,13 +66,35 @@ export interface OpenCodeManager {
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) return false;
|
||||
if (process.platform === 'win32') return true;
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return fs.statSync(filePath).isFile();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBinaryFromPath(binaryName: string, searchPath: string): string | null {
|
||||
if (!binaryName) return null;
|
||||
if (path.isAbsolute(binaryName)) {
|
||||
return isExecutable(binaryName) ? binaryName : null;
|
||||
}
|
||||
const directories = searchPath.split(path.delimiter).filter(Boolean);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
const candidate = path.join(directory, binaryName);
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLoginShellPath(): string | null {
|
||||
if (process.platform === 'win32') {
|
||||
return null;
|
||||
@@ -126,17 +149,47 @@ function buildAugmentedPath(): string {
|
||||
function resolveCliPath(): string | null {
|
||||
// First check explicit candidates
|
||||
for (const candidate of BIN_CANDIDATES) {
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
if (!candidate) continue;
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (process.platform === 'win32' && !candidate.toLowerCase().endsWith('.exe')) {
|
||||
const withExe = `${candidate}.exe`;
|
||||
if (isExecutable(withExe)) {
|
||||
return withExe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then search in augmented PATH
|
||||
const augmentedPath = buildAugmentedPath();
|
||||
for (const segment of augmentedPath.split(path.delimiter)) {
|
||||
const candidate = path.join(segment, 'opencode');
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const result = spawnSync('where', ['opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: augmentedPath },
|
||||
});
|
||||
if (result.status === 0 && typeof result.stdout === 'string') {
|
||||
const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (isExecutable(line)) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const fromPath = resolveBinaryFromPath('opencode.exe', augmentedPath);
|
||||
if (fromPath) {
|
||||
return fromPath;
|
||||
}
|
||||
} else {
|
||||
const fromPath = resolveBinaryFromPath('opencode', augmentedPath);
|
||||
if (fromPath) {
|
||||
return fromPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +245,9 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.ok) {
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
||||
if (response.ok && isJson) {
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
@@ -208,6 +263,85 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appendDirectoryQuery = (url: string, directory: string | null | undefined): string => {
|
||||
const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null;
|
||||
if (!dir) return url;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('directory', dir);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
async function checkReady(apiUrl: string, directory: string | null | undefined, quick = false): Promise<boolean> {
|
||||
const normalized = apiUrl.replace(/\/+$/, '');
|
||||
const targets: Array<{ path: string; timeoutMs: number }> = [
|
||||
{ path: '/config', timeoutMs: quick ? 1500 : 4000 },
|
||||
{ path: '/config/providers', timeoutMs: quick ? 2000 : 6000 },
|
||||
{ path: '/agent', timeoutMs: quick ? 2500 : 10000 },
|
||||
{ path: '/session/status', timeoutMs: quick ? 2000 : 6000 },
|
||||
];
|
||||
|
||||
for (const target of targets) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
try {
|
||||
const url = appendDirectoryQuery(`${normalized}${target.path}`, directory);
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
||||
if (!isJson) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isTcpPortAvailable(port: number): Promise<boolean> {
|
||||
if (!Number.isFinite(port) || port <= 0) return false;
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
|
||||
server.once('error', () => resolve(false));
|
||||
server.listen({ host: '127.0.0.1', port }, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getEphemeralPort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once('error', (err) => reject(err));
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate ephemeral port')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
@@ -224,7 +358,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
// Port detection state (like desktop)
|
||||
let detectedPort: number | null = null;
|
||||
let portWaiters: Array<(port: number) => void> = [];
|
||||
|
||||
// OpenCode API prefix detection (some versions serve under /api)
|
||||
let apiPrefix: string = '';
|
||||
@@ -280,6 +413,31 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return `http://localhost:${port}${prefix}`;
|
||||
};
|
||||
|
||||
const probeOpenCodeAtPort = async (port: number, quick = false): Promise<string | null> => {
|
||||
if (!Number.isFinite(port) || port <= 0) return null;
|
||||
const origin = `http://localhost:${port}`;
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
const base = `${origin}${candidate}`;
|
||||
if (await checkReady(base, workingDirectory, quick)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const waitForOpenCodeReadyAtPort = async (port: number, timeoutMs: number): Promise<boolean> => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const prefix = await probeOpenCodeAtPort(port, true);
|
||||
if (prefix !== null) {
|
||||
setDetectedApiPrefix(prefix);
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const detectApiPrefixFromOutput = (text: string) => {
|
||||
if (!text) return;
|
||||
URL_REGEX.lastIndex = 0;
|
||||
@@ -297,56 +455,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
};
|
||||
|
||||
const extractPrefixFromOpenApiDoc = (content: string): string | null => {
|
||||
const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
const parsed = new URL(match[1], 'http://localhost');
|
||||
return normalizeApiPrefix(parsed.pathname || '');
|
||||
} catch {
|
||||
return normalizeApiPrefix(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const detectApiPrefix = async (port: number): Promise<string> => {
|
||||
if (apiPrefixDetected) return apiPrefix;
|
||||
|
||||
const origin = `http://localhost:${port}`;
|
||||
|
||||
// Try /doc for explicit base hints first (best signal when available).
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
const prefix = normalizeApiPrefix(candidate);
|
||||
try {
|
||||
const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } });
|
||||
if (!response.ok) continue;
|
||||
const text = await response.text();
|
||||
const extracted = extractPrefixFromOpenApiDoc(text);
|
||||
if (extracted !== null) {
|
||||
setDetectedApiPrefix(extracted);
|
||||
return apiPrefix;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: probe a stable endpoint under root vs /api.
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
try {
|
||||
const base = buildApiBaseUrlFromPort(port, candidate);
|
||||
const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) continue;
|
||||
await response.json().catch(() => null);
|
||||
setDetectedApiPrefix(candidate);
|
||||
return apiPrefix;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return apiPrefix;
|
||||
};
|
||||
|
||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||
if (status !== newStatus || lastError !== error) {
|
||||
status = newStatus;
|
||||
@@ -359,20 +467,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
|
||||
function setDetectedPort(port: number) {
|
||||
if (detectedPort !== port) {
|
||||
detectedPort = port;
|
||||
|
||||
// Notify all waiters
|
||||
const waiters = portWaiters;
|
||||
portWaiters = [];
|
||||
for (const notify of waiters) {
|
||||
try {
|
||||
notify(port);
|
||||
} catch {
|
||||
// Ignore waiter errors
|
||||
}
|
||||
}
|
||||
}
|
||||
detectedPort = port;
|
||||
}
|
||||
|
||||
function detectPortFromOutput(text: string) {
|
||||
@@ -382,6 +477,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
const port = parseInt(match[1], 10);
|
||||
if (Number.isFinite(port) && port > 0) {
|
||||
if (detectedPort !== null && detectedPort !== port) {
|
||||
return;
|
||||
}
|
||||
setDetectedPort(port);
|
||||
const inferred = inferPrefixFromLogPath(match[2] || '');
|
||||
if (inferred !== null) {
|
||||
@@ -396,45 +494,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
if (fallbackMatch) {
|
||||
const port = parseInt(fallbackMatch[1], 10);
|
||||
if (Number.isFinite(port) && port > 0) {
|
||||
if (detectedPort !== null && detectedPort !== port) {
|
||||
return;
|
||||
}
|
||||
setDetectedPort(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPort(timeoutMs: number): Promise<number> {
|
||||
if (detectedPort !== null) {
|
||||
return detectedPort;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onPortDetected = (port: number) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(port);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
portWaiters = portWaiters.filter(cb => cb !== onPortDetected);
|
||||
reject(new Error('Timed out waiting for OpenCode port detection'));
|
||||
}, timeoutMs);
|
||||
|
||||
portWaiters.push(onPortDetected);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReady(apiUrl: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
// Use quick health check during startup for faster response
|
||||
if (await checkHealth(apiUrl, true)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getApiUrl(): string | null {
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
return configuredApiUrl.replace(/\/+$/, '');
|
||||
@@ -483,7 +550,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
// If user configured an external API URL, do NOT start a local CLI instance.
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
setStatus('connecting');
|
||||
const healthy = await checkHealth(configuredApiUrl);
|
||||
const healthy = await checkReady(configuredApiUrl, workingDirectory, false);
|
||||
if (healthy) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
@@ -495,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
// Check for existing running instance (only if port is known)
|
||||
const currentUrl = getApiUrl();
|
||||
if (currentUrl && await checkHealth(currentUrl)) {
|
||||
if (currentUrl && await checkReady(currentUrl, workingDirectory, false)) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
return;
|
||||
@@ -524,22 +591,29 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
// Use port 0 for dynamic assignment unless user configured a specific port
|
||||
const portArg = configuredPort !== null ? configuredPort.toString() : '0';
|
||||
|
||||
try {
|
||||
const portToUse = await (async () => {
|
||||
if (await isTcpPortAvailable(DEFAULT_OPENCODE_PORT)) {
|
||||
return DEFAULT_OPENCODE_PORT;
|
||||
}
|
||||
return await getEphemeralPort();
|
||||
})();
|
||||
|
||||
const augmentedEnv = {
|
||||
...process.env,
|
||||
PATH: buildAugmentedPath(),
|
||||
};
|
||||
|
||||
childProcess = spawn(cliPath!, ['serve', '--port', portArg], {
|
||||
childProcess = spawn(cliPath!, ['serve', '--port', portToUse.toString()], {
|
||||
cwd: spawnCwd,
|
||||
env: augmentedEnv,
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// We picked the port explicitly, so we don't need to wait for log-based detection.
|
||||
setDetectedPort(portToUse);
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
detectPortFromOutput(text);
|
||||
@@ -566,34 +640,13 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
lastExitCode = typeof code === 'number' ? code : null;
|
||||
});
|
||||
|
||||
// Wait for port detection (port comes from stdout/stderr)
|
||||
try {
|
||||
await waitForPort(PORT_DETECTION_TIMEOUT_MS);
|
||||
} catch {
|
||||
setStatus('error', 'OpenCode did not report port in time');
|
||||
await stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Now wait for API to be ready
|
||||
const detected = detectedPort;
|
||||
if (detected !== null && !apiPrefixDetected) {
|
||||
await detectApiPrefix(detected);
|
||||
}
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) {
|
||||
setStatus('error', 'Failed to determine OpenCode API URL');
|
||||
await stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const ready = await waitForReady(apiUrl, READY_CHECK_TIMEOUT_MS);
|
||||
const ready = await waitForOpenCodeReadyAtPort(portToUse, READY_CHECK_TIMEOUT_MS);
|
||||
if (ready) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
} else {
|
||||
setStatus('error', 'OpenCode API did not become ready in time');
|
||||
await stop();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -635,6 +688,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
if (target === workingDirectory) {
|
||||
return { success: true, restarted: false, path: target };
|
||||
}
|
||||
|
||||
// Track requested directory for UI + path resolution.
|
||||
// OpenCode requests should use the `directory` parameter instead of relying on process cwd.
|
||||
workingDirectory = target;
|
||||
|
||||
// When pointing at an external API URL, avoid restarting a local CLI process.
|
||||
@@ -642,8 +698,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return { success: true, restarted: false, path: target };
|
||||
}
|
||||
|
||||
await restart();
|
||||
return { success: true, restarted: true, path: target };
|
||||
return { success: true, restarted: false, path: target };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -172,9 +172,37 @@ const writePromptFile = (filePath: string, content: string) => {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all possible project config paths in priority order
|
||||
* Priority: root > .opencode/, json > jsonc
|
||||
*/
|
||||
const getProjectConfigCandidates = (workingDirectory?: string): string[] => {
|
||||
if (!workingDirectory) return [];
|
||||
return [
|
||||
path.join(workingDirectory, 'opencode.json'),
|
||||
path.join(workingDirectory, 'opencode.jsonc'),
|
||||
path.join(workingDirectory, '.opencode', 'opencode.json'),
|
||||
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find existing project config file or return default path for new config
|
||||
*/
|
||||
const getProjectConfigPath = (workingDirectory?: string): string | null => {
|
||||
if (!workingDirectory) return null;
|
||||
return path.join(workingDirectory, 'opencode.json');
|
||||
|
||||
const candidates = getProjectConfigCandidates(workingDirectory);
|
||||
|
||||
// Return first existing config file
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to root opencode.json for new configs
|
||||
return candidates[0] || null;
|
||||
};
|
||||
|
||||
const getConfigPaths = (workingDirectory?: string) => ({
|
||||
@@ -289,7 +317,14 @@ const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>;
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content.trim() };
|
||||
return { frontmatter: (yaml.parse(match[1]) || {}) as Record<string, unknown>, body: (match[2] || '').trim() };
|
||||
let frontmatter: Record<string, unknown> = {};
|
||||
try {
|
||||
frontmatter = (yaml.parse(match[1]) || {}) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
console.warn(`[OpenChamber][VSCode] Failed to parse frontmatter for ${filePath}, treating as empty:`, error);
|
||||
frontmatter = {};
|
||||
}
|
||||
return { frontmatter, body: (match[2] || '').trim() };
|
||||
};
|
||||
|
||||
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
|
||||
@@ -541,10 +576,11 @@ export const getCommandSources = (commandName: string, workingDirectory?: string
|
||||
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||
const commandSection = jsonSource.section as Record<string, unknown> | undefined;
|
||||
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
|
||||
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
|
||||
|
||||
const sources: ConfigSources = {
|
||||
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
|
||||
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, fields: [] },
|
||||
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
|
||||
projectMd: { exists: projectExists, path: projectPath },
|
||||
userMd: { exists: userExists, path: userPath }
|
||||
};
|
||||
@@ -1141,4 +1177,3 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user