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:
Bohdan Triapitsyn
2026-01-06 21:31:04 +02:00
committed by GitHub
parent 8aa379e313
commit 18c5b4c7b5
84 changed files with 8399 additions and 2854 deletions
+308 -31
View File
@@ -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,