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
@@ -101,22 +101,72 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
|
||||
if (existing.path) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
// For new agents or built-in overrides: use requested scope or default to user
|
||||
const scope = requestedScope || AGENT_SCOPE.USER;
|
||||
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
|
||||
return {
|
||||
scope: AGENT_SCOPE.PROJECT,
|
||||
path: getProjectAgentPath(workingDirectory, agentName)
|
||||
return {
|
||||
scope: AGENT_SCOPE.PROJECT,
|
||||
path: getProjectAgentPath(workingDirectory, agentName)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scope: AGENT_SCOPE.USER,
|
||||
path: getUserAgentPath(agentName)
|
||||
|
||||
return {
|
||||
scope: AGENT_SCOPE.USER,
|
||||
path: getUserAgentPath(agentName)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect where an agent's permission field is currently defined
|
||||
* Priority: project .md > user .md > project JSON > user JSON
|
||||
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
|
||||
*/
|
||||
function getAgentPermissionSource(agentName, workingDirectory) {
|
||||
// Check project-level .md first
|
||||
if (workingDirectory) {
|
||||
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
|
||||
if (fs.existsSync(projectMdPath)) {
|
||||
const { frontmatter } = parseMdFile(projectMdPath);
|
||||
if (frontmatter.permission !== undefined) {
|
||||
return { source: 'md', scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check user-level .md
|
||||
const userMdPath = getUserAgentPath(agentName);
|
||||
if (fs.existsSync(userMdPath)) {
|
||||
const { frontmatter } = parseMdFile(userMdPath);
|
||||
if (frontmatter.permission !== undefined) {
|
||||
return { source: 'md', scope: AGENT_SCOPE.USER, path: userMdPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Check JSON layers (project > user)
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
|
||||
// Project opencode.json
|
||||
const projectJsonPermission = layers.projectConfig?.agent?.[agentName]?.permission;
|
||||
if (projectJsonPermission !== undefined && layers.paths.projectPath) {
|
||||
return { source: 'json', scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
|
||||
}
|
||||
|
||||
// User opencode.json
|
||||
const userJsonPermission = layers.userConfig?.agent?.[agentName]?.permission;
|
||||
if (userJsonPermission !== undefined) {
|
||||
return { source: 'json', scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
|
||||
}
|
||||
|
||||
// Custom config (env var)
|
||||
const customJsonPermission = layers.customConfig?.agent?.[agentName]?.permission;
|
||||
if (customJsonPermission !== undefined && layers.paths.customPath) {
|
||||
return { source: 'json', scope: 'custom', path: layers.paths.customPath };
|
||||
}
|
||||
|
||||
return { source: null, scope: null, path: null };
|
||||
}
|
||||
|
||||
// ============== COMMAND SCOPE HELPERS ==============
|
||||
|
||||
/**
|
||||
@@ -412,9 +462,125 @@ function writePromptFile(filePath, content) {
|
||||
console.log(`Updated prompt file: ${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all possible project config paths in priority order
|
||||
* Priority: root > .opencode/, json > jsonc
|
||||
*/
|
||||
function getProjectConfigCandidates(workingDirectory) {
|
||||
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
|
||||
*/
|
||||
function getProjectConfigPath(workingDirectory) {
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new permission config with existing non-wildcard patterns
|
||||
* Non-wildcard patterns (patterns other than "*") are preserved from existing config
|
||||
* @param {object|string|null} newPermission - New permission config from UI (wildcards only)
|
||||
* @param {object} permissionSource - Result from getAgentPermissionSource
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string|null} workingDirectory - Working directory
|
||||
* @returns {object|string|null} Merged permission config
|
||||
*/
|
||||
function mergePermissionWithNonWildcards(newPermission, permissionSource, agentName, workingDirectory) {
|
||||
// If no existing permission, return new permission as-is
|
||||
if (!permissionSource.source || !permissionSource.path) {
|
||||
return newPermission;
|
||||
}
|
||||
|
||||
// Get existing permission config
|
||||
let existingPermission = null;
|
||||
if (permissionSource.source === 'md') {
|
||||
const { frontmatter } = parseMdFile(permissionSource.path);
|
||||
existingPermission = frontmatter.permission;
|
||||
} else if (permissionSource.source === 'json') {
|
||||
const config = readConfigFile(permissionSource.path);
|
||||
existingPermission = config?.agent?.[agentName]?.permission;
|
||||
}
|
||||
|
||||
// If no existing permission or it's a simple string, return new permission as-is
|
||||
if (!existingPermission || typeof existingPermission === 'string') {
|
||||
return newPermission;
|
||||
}
|
||||
|
||||
// If new permission is null/undefined, return null to clear it
|
||||
if (newPermission == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If new permission is a simple string (e.g., "allow"), return it as-is
|
||||
if (typeof newPermission === 'string') {
|
||||
return newPermission;
|
||||
}
|
||||
|
||||
// Extract non-wildcard patterns from existing permission
|
||||
const nonWildcardPatterns = {};
|
||||
for (const [permKey, permValue] of Object.entries(existingPermission)) {
|
||||
if (permKey === '*') continue; // Skip global default
|
||||
|
||||
if (typeof permValue === 'object' && permValue !== null && !Array.isArray(permValue)) {
|
||||
// Permission has pattern-based config (e.g., { "npm *": "allow", "*": "ask" })
|
||||
const nonWildcards = {};
|
||||
for (const [pattern, action] of Object.entries(permValue)) {
|
||||
if (pattern !== '*') {
|
||||
nonWildcards[pattern] = action;
|
||||
}
|
||||
}
|
||||
if (Object.keys(nonWildcards).length > 0) {
|
||||
nonWildcardPatterns[permKey] = nonWildcards;
|
||||
}
|
||||
}
|
||||
// Simple string values (e.g., "allow") don't have patterns, skip them
|
||||
}
|
||||
|
||||
// If no non-wildcard patterns to preserve, return new permission as-is
|
||||
if (Object.keys(nonWildcardPatterns).length === 0) {
|
||||
return newPermission;
|
||||
}
|
||||
|
||||
// Merge non-wildcards into new permission
|
||||
const merged = { ...newPermission };
|
||||
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
|
||||
const newValue = merged[permKey];
|
||||
if (typeof newValue === 'string') {
|
||||
// Convert string to object with wildcard + preserved patterns
|
||||
merged[permKey] = { '*': newValue, ...patterns };
|
||||
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||
// Merge patterns, new wildcards take precedence
|
||||
merged[permKey] = { ...patterns, ...newValue };
|
||||
} else {
|
||||
// Permission not in new config - preserve existing patterns with their wildcard if it existed
|
||||
const existingValue = existingPermission[permKey];
|
||||
if (typeof existingValue === 'object' && existingValue !== null) {
|
||||
const wildcard = existingValue['*'];
|
||||
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function getConfigPaths(workingDirectory) {
|
||||
@@ -539,22 +705,23 @@ function getJsonWriteTarget(layers, preferredScope) {
|
||||
}
|
||||
|
||||
function parseMdFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
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() };
|
||||
}
|
||||
|
||||
const frontmatter = yaml.parse(match[1]) || {};
|
||||
const body = match[2].trim();
|
||||
|
||||
return { frontmatter, body };
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse markdown file ${filePath}:`, error);
|
||||
throw new Error('Failed to parse agent markdown file');
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content.trim() };
|
||||
}
|
||||
|
||||
let frontmatter = {};
|
||||
try {
|
||||
frontmatter = yaml.parse(match[1]) || {};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
|
||||
frontmatter = {};
|
||||
}
|
||||
|
||||
const body = match[2].trim();
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
function writeMdFile(filePath, frontmatter, body) {
|
||||
@@ -606,12 +773,6 @@ function getAgentSources(agentName, workingDirectory) {
|
||||
scope: jsonSource.exists ? jsonScope : null,
|
||||
fields: []
|
||||
},
|
||||
json: {
|
||||
exists: jsonSource.exists,
|
||||
path: jsonPath,
|
||||
scope: jsonSource.exists ? jsonScope : null,
|
||||
fields: []
|
||||
},
|
||||
// Additional info about both levels
|
||||
projectMd: {
|
||||
exists: projectExists,
|
||||
@@ -638,6 +799,48 @@ function getAgentSources(agentName, workingDirectory) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
function getAgentConfig(agentName, workingDirectory) {
|
||||
// Prefer markdown agents (project > user)
|
||||
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
|
||||
const userPath = getUserAgentPath(agentName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
|
||||
if (projectExists || userExists) {
|
||||
const mdPath = projectExists ? projectPath : userPath;
|
||||
const { frontmatter, body } = parseMdFile(mdPath);
|
||||
|
||||
return {
|
||||
source: 'md',
|
||||
scope: projectExists ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER,
|
||||
config: {
|
||||
...frontmatter,
|
||||
...(typeof body === 'string' && body.length > 0 ? { prompt: body } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Then fall back to opencode.json (highest-precedence entry)
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
|
||||
|
||||
if (jsonSource.exists && jsonSource.section) {
|
||||
const scope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
|
||||
return {
|
||||
source: 'json',
|
||||
scope,
|
||||
config: { ...jsonSource.section },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'none',
|
||||
scope: null,
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createAgent(agentName, config, workingDirectory, scope) {
|
||||
ensureDirs();
|
||||
|
||||
@@ -693,7 +896,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
const hasJsonFields = jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0;
|
||||
const jsonTarget = jsonSource.exists
|
||||
? { config: jsonSource.config, path: jsonSource.path }
|
||||
: getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
|
||||
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
||||
let config = jsonTarget.config || {};
|
||||
|
||||
// Determine if we should create a new md file:
|
||||
@@ -742,7 +945,7 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
jsonModified = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// For JSON-only agents, store prompt inline in JSON
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
@@ -751,9 +954,79 @@ function updateAgent(agentName, updates, workingDirectory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for permission field - uses location detection and preserves non-wildcards
|
||||
if (field === 'permission') {
|
||||
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
|
||||
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName, workingDirectory);
|
||||
|
||||
if (permissionSource.source === 'md') {
|
||||
// Write to existing .md file
|
||||
const existingMdData = parseMdFile(permissionSource.path);
|
||||
existingMdData.frontmatter.permission = newPermission;
|
||||
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
|
||||
console.log(`Updated permission in .md file: ${permissionSource.path}`);
|
||||
} else if (permissionSource.source === 'json') {
|
||||
// Write to existing JSON location
|
||||
const existingConfig = readConfigFile(permissionSource.path);
|
||||
if (!existingConfig.agent) existingConfig.agent = {};
|
||||
if (!existingConfig.agent[agentName]) existingConfig.agent[agentName] = {};
|
||||
existingConfig.agent[agentName].permission = newPermission;
|
||||
writeConfig(existingConfig, permissionSource.path);
|
||||
console.log(`Updated permission in JSON: ${permissionSource.path}`);
|
||||
} else {
|
||||
// Permission not defined anywhere - use agent's source location
|
||||
if ((mdExists || creatingNewMd) && mdData) {
|
||||
mdData.frontmatter.permission = newPermission;
|
||||
mdModified = true;
|
||||
} else if (hasJsonFields) {
|
||||
// Agent exists in JSON - add permission there
|
||||
if (!config.agent) config.agent = {};
|
||||
if (!config.agent[agentName]) config.agent[agentName] = {};
|
||||
config.agent[agentName].permission = newPermission;
|
||||
jsonModified = true;
|
||||
} else {
|
||||
// Built-in agent with no config - write to project JSON if available, else user JSON
|
||||
const writeTarget = workingDirectory
|
||||
? { config: layers.projectConfig || {}, path: layers.paths.projectPath || layers.paths.userPath }
|
||||
: { config: layers.userConfig || {}, path: layers.paths.userPath };
|
||||
if (!writeTarget.config.agent) writeTarget.config.agent = {};
|
||||
if (!writeTarget.config.agent[agentName]) writeTarget.config.agent[agentName] = {};
|
||||
writeTarget.config.agent[agentName].permission = newPermission;
|
||||
writeConfig(writeTarget.config, writeTarget.path);
|
||||
console.log(`Created permission in JSON: ${writeTarget.path}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const inMd = mdData?.frontmatter?.[field] !== undefined;
|
||||
const inJson = jsonSection?.[field] !== undefined;
|
||||
|
||||
if (value === null) {
|
||||
// Treat null as a request to remove the field.
|
||||
if (mdData && inMd) {
|
||||
delete mdData.frontmatter[field];
|
||||
mdModified = true;
|
||||
}
|
||||
|
||||
if (inJson) {
|
||||
if (config.agent?.[agentName]) {
|
||||
delete config.agent[agentName][field];
|
||||
|
||||
if (Object.keys(config.agent[agentName]).length === 0) {
|
||||
delete config.agent[agentName];
|
||||
}
|
||||
if (Object.keys(config.agent).length === 0) {
|
||||
delete config.agent;
|
||||
}
|
||||
|
||||
jsonModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// JSON takes precedence over md, so update JSON first if field exists there
|
||||
if (inJson) {
|
||||
if (!config.agent) config.agent = {};
|
||||
@@ -852,6 +1125,7 @@ function getCommandSources(commandName, workingDirectory) {
|
||||
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
|
||||
const jsonSection = jsonSource.section;
|
||||
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 = {
|
||||
md: {
|
||||
@@ -863,6 +1137,7 @@ function getCommandSources(commandName, workingDirectory) {
|
||||
json: {
|
||||
exists: jsonSource.exists,
|
||||
path: jsonPath,
|
||||
scope: jsonSource.exists ? jsonScope : null,
|
||||
fields: []
|
||||
},
|
||||
// Additional info about both levels
|
||||
@@ -1373,6 +1648,8 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
export {
|
||||
getAgentSources,
|
||||
getAgentScope,
|
||||
getAgentPermissionSource,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
|
||||
Reference in New Issue
Block a user