@shawnstack/quickforge 1.3.25 → 1.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +20 -14
  2. package/dist/assets/{anthropic-B1_Yrokl.js → anthropic-BcnDL7hi.js} +1 -1
  3. package/dist/assets/{azure-openai-responses-UMiOBCBd.js → azure-openai-responses-BEfdv0qd.js} +1 -1
  4. package/dist/assets/{google-BLE_Gcd1.js → google-C2y985rW.js} +1 -1
  5. package/dist/assets/{google-vertex-6_sIZLVc.js → google-vertex-Jf9zNsCF.js} +1 -1
  6. package/dist/assets/{icons-Bs7OG8yi.js → icons-BVM5--R9.js} +1 -1
  7. package/dist/assets/{index-C3bc5C3k.js → index-8Q1Ovled.js} +595 -547
  8. package/dist/assets/index-ZYbEKGUp.css +3 -0
  9. package/dist/assets/{mistral-DmZEmRkv.js → mistral-qYbgRY3z.js} +1 -1
  10. package/dist/assets/{openai-codex-responses-i_SmQGzQ.js → openai-codex-responses--aAgyYJM.js} +1 -1
  11. package/dist/assets/{openai-completions-BmmZFDDY.js → openai-completions-CHDluyXM.js} +1 -1
  12. package/dist/assets/{openai-responses-C8tPdeE9.js → openai-responses-UtRriBXu.js} +1 -1
  13. package/dist/assets/{openai-responses-shared-DchtjQNp.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
  14. package/dist/assets/{openrouter-CcTv1G_v.js → openrouter-Dz9zwzUG.js} +1 -1
  15. package/dist/assets/{react-vendor-Cu-7p9CI.js → react-vendor-DAoL5p8_.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +2 -1
  18. package/server/agent-manager.mjs +102 -22
  19. package/server/approval-store.mjs +1 -1
  20. package/server/custom-commands.mjs +67 -9
  21. package/server/index.mjs +7 -0
  22. package/server/plugins/loader.mjs +56 -0
  23. package/server/plugins/manifest.mjs +174 -0
  24. package/server/plugins/registry.mjs +304 -0
  25. package/server/project-config.mjs +53 -4
  26. package/server/routes/agent.mjs +1 -16
  27. package/server/routes/plugins.mjs +63 -0
  28. package/server/routes/project.mjs +2 -0
  29. package/server/routes/tools.mjs +12 -1
  30. package/server/skills.mjs +64 -5
  31. package/server/storage.mjs +13 -6
  32. package/server/system-prompt.mjs +27 -5
  33. package/server/tool-wiring.mjs +26 -0
  34. package/dist/assets/index-C7oT9Rdw.css +0 -3
@@ -0,0 +1,174 @@
1
+ import path from 'node:path'
2
+
3
+ export const PLUGIN_API_VERSION = 1
4
+
5
+ const pluginNamePattern = /^[a-z0-9][a-z0-9_-]{0,63}$/
6
+ const toolNamePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
7
+
8
+ function isPlainObject(value) {
9
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
10
+ }
11
+
12
+ function asString(value, fallback = '') {
13
+ return typeof value === 'string' ? value.trim() : fallback
14
+ }
15
+
16
+ function normalizeStringArray(value) {
17
+ if (!Array.isArray(value)) return []
18
+ const result = []
19
+ const seen = new Set()
20
+ for (const item of value) {
21
+ const text = asString(item)
22
+ if (!text || seen.has(text)) continue
23
+ seen.add(text)
24
+ result.push(text)
25
+ }
26
+ return result
27
+ }
28
+
29
+ function addPermission(result, seen, value) {
30
+ const text = asString(value)
31
+ if (!text || seen.has(text)) return
32
+ seen.add(text)
33
+ result.push(text)
34
+ }
35
+
36
+ function normalizePermissions(value) {
37
+ if (Array.isArray(value)) return normalizeStringArray(value)
38
+ if (!isPlainObject(value)) return []
39
+
40
+ const result = []
41
+ const seen = new Set()
42
+ for (const [key, item] of Object.entries(value)) {
43
+ if (!key) continue
44
+ if (Array.isArray(item)) {
45
+ for (const permission of item) addPermission(result, seen, `${key}:${permission}`)
46
+ } else if (isPlainObject(item)) {
47
+ addPermission(result, seen, `${key}:${JSON.stringify(item)}`)
48
+ } else {
49
+ addPermission(result, seen, `${key}:${item}`)
50
+ }
51
+ }
52
+ return result
53
+ }
54
+
55
+ function normalizeSchema(value) {
56
+ return isPlainObject(value) ? value : { type: 'object', properties: {} }
57
+ }
58
+
59
+ function isPathInside(root, target) {
60
+ const relative = path.relative(root, target)
61
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
62
+ }
63
+
64
+ function normalizeRelativePath(value, fieldName, pluginName, pluginDir) {
65
+ const rawPath = asString(value).replace(/\\/g, '/')
66
+ if (!rawPath) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path is required.`)
67
+ if (rawPath.includes('\0')) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path contains invalid characters.`)
68
+ if (path.isAbsolute(rawPath)) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path must be relative: ${rawPath}`)
69
+
70
+ const resolvedPath = path.resolve(pluginDir, rawPath)
71
+ if (!isPathInside(pluginDir, resolvedPath)) {
72
+ throw new Error(`Plugin ${pluginName} ${fieldName} contribution path is outside the plugin directory: ${rawPath}`)
73
+ }
74
+
75
+ return {
76
+ path: rawPath.replace(/^\.\//, ''),
77
+ resolvedPath,
78
+ }
79
+ }
80
+
81
+ function normalizePathContributions(value, fieldName, pluginName, pluginDir) {
82
+ const entries = typeof value === 'string' ? [value] : Array.isArray(value) ? value : isPlainObject(value) ? [value] : []
83
+ const result = []
84
+ const seen = new Set()
85
+
86
+ for (const entry of entries) {
87
+ const rawPath = typeof entry === 'string'
88
+ ? entry
89
+ : isPlainObject(entry)
90
+ ? entry.path || entry.dir || entry.file
91
+ : ''
92
+ const normalized = normalizeRelativePath(rawPath, fieldName, pluginName, pluginDir)
93
+ const key = normalized.path.toLowerCase()
94
+ if (seen.has(key)) continue
95
+ seen.add(key)
96
+ result.push(normalized)
97
+ }
98
+
99
+ return result
100
+ }
101
+
102
+ function normalizeTool(tool, pluginName) {
103
+ if (!isPlainObject(tool)) throw new Error(`Plugin ${pluginName} tool entry must be an object.`)
104
+ const name = asString(tool.name)
105
+ if (!toolNamePattern.test(name)) {
106
+ throw new Error(`Plugin ${pluginName} has invalid tool name: ${name || '(empty)'}. Use letters, numbers, underscore, or hyphen.`)
107
+ }
108
+ return {
109
+ name,
110
+ label: asString(tool.label || tool.title, name),
111
+ description: asString(tool.description, name),
112
+ parameters: normalizeSchema(tool.parameters || tool.inputSchema),
113
+ executionMode: asString(tool.executionMode) || undefined,
114
+ }
115
+ }
116
+
117
+ export function quickForgePluginToolName(pluginName, toolName) {
118
+ return `plugin__${pluginName}__${toolName.replace(/[^A-Za-z0-9_-]/g, '_')}`
119
+ }
120
+
121
+ export function parseQuickForgePluginToolName(value) {
122
+ const name = String(value || '')
123
+ if (!name.startsWith('plugin__')) return null
124
+ const rest = name.slice('plugin__'.length)
125
+ const index = rest.indexOf('__')
126
+ if (index <= 0 || index >= rest.length - 2) return null
127
+ return {
128
+ pluginName: rest.slice(0, index),
129
+ toolName: rest.slice(index + 2),
130
+ }
131
+ }
132
+
133
+ export function isPluginToolName(name) {
134
+ return Boolean(parseQuickForgePluginToolName(name))
135
+ }
136
+
137
+ export function normalizePluginManifest(raw, pluginDir) {
138
+ if (!isPlainObject(raw)) throw new Error('plugin.json must contain an object.')
139
+
140
+ const name = asString(raw.name)
141
+ if (!pluginNamePattern.test(name)) {
142
+ throw new Error(`Invalid plugin name: ${name || '(empty)'}. Use lowercase letters, numbers, underscore, or hyphen.`)
143
+ }
144
+
145
+ const apiVersion = Number(raw.apiVersion || PLUGIN_API_VERSION)
146
+ if (apiVersion !== PLUGIN_API_VERSION) {
147
+ throw new Error(`Unsupported plugin apiVersion ${apiVersion}. Current QuickForge plugin API is ${PLUGIN_API_VERSION}.`)
148
+ }
149
+
150
+ const contributes = isPlainObject(raw.contributes) ? raw.contributes : {}
151
+ const tools = Array.isArray(contributes.tools)
152
+ ? contributes.tools.map((tool) => normalizeTool(tool, name))
153
+ : []
154
+
155
+ return {
156
+ name,
157
+ displayName: asString(raw.displayName || raw.title, name),
158
+ version: asString(raw.version, '0.0.0'),
159
+ description: asString(raw.description),
160
+ apiVersion,
161
+ quickforgeVersion: asString(raw.quickforgeVersion),
162
+ enabledByDefault: raw.enabledByDefault === true,
163
+ main: asString(raw.main, 'index.mjs'),
164
+ permissions: normalizePermissions(raw.permissions),
165
+ contributes: {
166
+ tools,
167
+ skills: normalizePathContributions(contributes.skills, 'skills', name, pluginDir),
168
+ commands: normalizePathContributions(contributes.commands, 'commands', name, pluginDir),
169
+ settings: isPlainObject(contributes.settings) ? contributes.settings : null,
170
+ },
171
+ dir: pluginDir,
172
+ manifestPath: path.join(pluginDir, 'plugin.json'),
173
+ }
174
+ }
@@ -0,0 +1,304 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dataDir, readStore, atomicUpdate } from '../storage.mjs'
6
+ import { logger } from '../utils/logger.mjs'
7
+ import { loadPlugin } from './loader.mjs'
8
+ import {
9
+ isPluginToolName,
10
+ normalizePluginManifest,
11
+ parseQuickForgePluginToolName,
12
+ quickForgePluginToolName,
13
+ } from './manifest.mjs'
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+ const bundledPluginDir = path.resolve(__dirname, '..', '..', 'plugins')
17
+ const globalPluginDir = path.join(dataDir, 'plugins')
18
+ const legacyGlobalPluginDir = path.join(os.homedir(), '.agents', 'plugins')
19
+ let cachedCatalog = null
20
+ let cachedCatalogKey = null
21
+ let refreshPromise = null
22
+
23
+ function catalogKey(projectContext = null) {
24
+ return projectContext?.workspaceRoot ? path.resolve(projectContext.workspaceRoot) : ''
25
+ }
26
+
27
+ function isPlainObject(value) {
28
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
29
+ }
30
+
31
+ async function exists(filePath) {
32
+ try {
33
+ await fs.access(filePath)
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ async function readJsonFile(filePath) {
41
+ const text = await fs.readFile(filePath, 'utf8')
42
+ return JSON.parse(text.trimStart())
43
+ }
44
+
45
+ async function listPluginDirs(root) {
46
+ let entries = []
47
+ try {
48
+ entries = await fs.readdir(root, { withFileTypes: true })
49
+ } catch (error) {
50
+ if (error?.code === 'ENOENT') return []
51
+ throw error
52
+ }
53
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(root, entry.name))
54
+ }
55
+
56
+ function normalizePluginStore(store) {
57
+ const source = isPlainObject(store) ? store : {}
58
+ return {
59
+ enabled: isPlainObject(source.enabled) ? source.enabled : {},
60
+ config: isPlainObject(source.config) ? source.config : {},
61
+ }
62
+ }
63
+
64
+ async function readPluginStore() {
65
+ return normalizePluginStore(await readStore('plugins'))
66
+ }
67
+
68
+ function contributionSummary(contribution) {
69
+ return {
70
+ path: contribution.path,
71
+ resolvedPath: contribution.resolvedPath,
72
+ }
73
+ }
74
+
75
+ async function discoverManifests(projectContext) {
76
+ const roots = [bundledPluginDir, globalPluginDir, legacyGlobalPluginDir]
77
+ if (projectContext?.workspaceRoot) roots.push(path.join(projectContext.workspaceRoot, '.quickforge', 'plugins'))
78
+
79
+ const manifests = new Map()
80
+ const errors = []
81
+ for (const root of roots) {
82
+ const dirs = await listPluginDirs(root)
83
+ for (const dir of dirs) {
84
+ const manifestPath = path.join(dir, 'plugin.json')
85
+ if (!(await exists(manifestPath))) continue
86
+ try {
87
+ const manifest = normalizePluginManifest(await readJsonFile(manifestPath), dir)
88
+ if (!manifests.has(manifest.name) || root !== legacyGlobalPluginDir) {
89
+ manifests.set(manifest.name, { manifest, sourceRoot: root })
90
+ }
91
+ } catch (error) {
92
+ errors.push({ dir, sourceRoot: root, error: error?.message || 'Failed to read plugin manifest' })
93
+ }
94
+ }
95
+ }
96
+ return { manifests, errors, roots }
97
+ }
98
+
99
+ async function loadEnabledPlugins(projectContext) {
100
+ const store = await readPluginStore()
101
+ const { manifests, errors, roots } = await discoverManifests(projectContext)
102
+ const plugins = []
103
+ const handlers = new Map()
104
+
105
+ for (const { manifest, sourceRoot } of manifests.values()) {
106
+ const enabled = store.enabled[manifest.name] === true || (manifest.enabledByDefault && store.enabled[manifest.name] !== false)
107
+ const config = isPlainObject(store.config[manifest.name]) ? store.config[manifest.name] : {}
108
+ const entry = {
109
+ ...manifest,
110
+ enabled,
111
+ config,
112
+ sourceRoot,
113
+ status: enabled ? 'loaded' : 'disabled',
114
+ error: null,
115
+ tools: manifest.contributes.tools.map((tool) => ({
116
+ ...tool,
117
+ quickForgeName: quickForgePluginToolName(manifest.name, tool.name),
118
+ })),
119
+ skills: manifest.contributes.skills.map(contributionSummary),
120
+ commands: manifest.contributes.commands.map(contributionSummary),
121
+ }
122
+
123
+ if (enabled && entry.tools.length > 0) {
124
+ try {
125
+ const loaded = await loadPlugin(manifest, { config, projectContext })
126
+ handlers.set(manifest.name, loaded)
127
+ } catch (error) {
128
+ logger.error(`Failed to load plugin ${manifest.name}:`, error)
129
+ entry.status = 'error'
130
+ entry.error = error?.message || 'Failed to load plugin'
131
+ }
132
+ }
133
+
134
+ plugins.push(entry)
135
+ }
136
+
137
+ plugins.sort((a, b) => a.name.localeCompare(b.name))
138
+ return { plugins, handlers, errors, roots }
139
+ }
140
+
141
+ export async function refreshPlugins(projectContext = null) {
142
+ const key = catalogKey(projectContext)
143
+ if (!refreshPromise) {
144
+ refreshPromise = loadEnabledPlugins(projectContext).then((catalog) => {
145
+ cachedCatalog = catalog
146
+ cachedCatalogKey = key
147
+ return catalog
148
+ }).finally(() => {
149
+ refreshPromise = null
150
+ })
151
+ }
152
+ return refreshPromise
153
+ }
154
+
155
+ async function getCatalog(projectContext = null) {
156
+ const key = catalogKey(projectContext)
157
+ if (!cachedCatalog || cachedCatalogKey !== key) return refreshPlugins(projectContext)
158
+ return cachedCatalog
159
+ }
160
+
161
+ function enabledLoadedPlugins(catalog) {
162
+ return catalog.plugins.filter((plugin) => plugin.enabled && plugin.status === 'loaded')
163
+ }
164
+
165
+ export async function getEnabledPluginSkillSources(projectContext = null) {
166
+ const catalog = await getCatalog(projectContext)
167
+ return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.skills.map((skill) => ({
168
+ pluginName: plugin.name,
169
+ source: `plugin:${plugin.name}`,
170
+ dir: skill.resolvedPath,
171
+ path: skill.path,
172
+ })))
173
+ }
174
+
175
+ export async function getEnabledPluginCommandSources(projectContext = null) {
176
+ const catalog = await getCatalog(projectContext)
177
+ return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.commands.map((command) => ({
178
+ pluginName: plugin.name,
179
+ source: `plugin:${plugin.name}`,
180
+ path: command.resolvedPath,
181
+ relativePath: command.path,
182
+ })))
183
+ }
184
+
185
+ export async function getPluginStatus(projectContext = null) {
186
+ const catalog = await refreshPlugins(projectContext)
187
+ return {
188
+ searchPaths: catalog.roots,
189
+ errors: catalog.errors,
190
+ plugins: catalog.plugins.map((plugin) => ({
191
+ name: plugin.name,
192
+ displayName: plugin.displayName,
193
+ version: plugin.version,
194
+ description: plugin.description,
195
+ apiVersion: plugin.apiVersion,
196
+ quickforgeVersion: plugin.quickforgeVersion,
197
+ enabledByDefault: plugin.enabledByDefault,
198
+ dir: plugin.dir,
199
+ sourceRoot: plugin.sourceRoot,
200
+ enabled: plugin.enabled,
201
+ status: plugin.status,
202
+ error: plugin.error,
203
+ permissions: plugin.permissions,
204
+ tools: plugin.tools.map((tool) => ({
205
+ name: tool.name,
206
+ quickForgeName: tool.quickForgeName,
207
+ label: tool.label,
208
+ description: tool.description,
209
+ })),
210
+ skills: plugin.skills.map((skill) => ({ path: skill.path })),
211
+ commands: plugin.commands.map((command) => ({ path: command.path })),
212
+ settings: plugin.contributes.settings,
213
+ config: plugin.config,
214
+ })),
215
+ }
216
+ }
217
+
218
+ export async function setPluginEnabled(name, enabled) {
219
+ await atomicUpdate('plugins', (store) => {
220
+ const next = normalizePluginStore(store)
221
+ next.enabled[name] = enabled === true
222
+ return next
223
+ })
224
+ cachedCatalog = null
225
+ cachedCatalogKey = null
226
+ }
227
+
228
+ export async function setPluginConfig(name, config) {
229
+ await atomicUpdate('plugins', (store) => {
230
+ const next = normalizePluginStore(store)
231
+ next.config[name] = isPlainObject(config) ? config : {}
232
+ return next
233
+ })
234
+ cachedCatalog = null
235
+ cachedCatalogKey = null
236
+ }
237
+
238
+ export async function createPluginToolDefinitions(projectContext = null) {
239
+ const catalog = await getCatalog(projectContext)
240
+ const definitions = []
241
+ for (const plugin of catalog.plugins) {
242
+ if (!plugin.enabled || plugin.status !== 'loaded') continue
243
+ for (const tool of plugin.tools) {
244
+ definitions.push({
245
+ name: tool.quickForgeName,
246
+ label: tool.label,
247
+ description: `[Plugin:${plugin.name}] ${tool.description || tool.name}`,
248
+ parameters: tool.parameters,
249
+ executionMode: tool.executionMode,
250
+ plugin: { name: plugin.name, toolName: tool.name },
251
+ })
252
+ }
253
+ }
254
+ return definitions
255
+ }
256
+
257
+ export { isPluginToolName }
258
+
259
+ export async function callPluginTool(toolName, params = {}, toolContext = {}) {
260
+ const parsed = parseQuickForgePluginToolName(toolName)
261
+ if (!parsed) {
262
+ const error = new Error(`Invalid plugin tool name: ${toolName}`)
263
+ error.statusCode = 400
264
+ throw error
265
+ }
266
+
267
+ const catalog = await getCatalog(toolContext)
268
+ const plugin = catalog.plugins.find((item) => item.name === parsed.pluginName)
269
+ if (!plugin || !plugin.enabled || plugin.status !== 'loaded') {
270
+ const error = new Error(`Plugin is not loaded: ${parsed.pluginName}`)
271
+ error.statusCode = 503
272
+ throw error
273
+ }
274
+
275
+ const tool = plugin.tools.find((item) => item.name === parsed.toolName || item.quickForgeName === toolName)
276
+ if (!tool) {
277
+ const error = new Error(`Unknown plugin tool: ${toolName}`)
278
+ error.statusCode = 404
279
+ throw error
280
+ }
281
+
282
+ const handler = catalog.handlers.get(parsed.pluginName)
283
+ if (!handler) {
284
+ const error = new Error(`Missing plugin handler: ${parsed.pluginName}`)
285
+ error.statusCode = 503
286
+ throw error
287
+ }
288
+
289
+ const result = await handler.callTool(tool.name, params, {
290
+ ...toolContext,
291
+ plugin: { name: plugin.name, dir: plugin.dir, config: plugin.config },
292
+ })
293
+
294
+ return {
295
+ isError: Boolean(result.isError),
296
+ content: result.content,
297
+ details: {
298
+ ...(isPlainObject(result.details) ? result.details : {}),
299
+ plugin: true,
300
+ pluginName: plugin.name,
301
+ tool: tool.name,
302
+ },
303
+ }
304
+ }
@@ -298,24 +298,71 @@ export async function readInstructionsFile(filePath) {
298
298
  return null
299
299
  }
300
300
 
301
+ async function readInstructionSources(candidates) {
302
+ const sources = []
303
+ const seen = new Set()
304
+
305
+ for (const candidate of candidates) {
306
+ const file = path.resolve(candidate.file)
307
+ if (seen.has(file)) continue
308
+ seen.add(file)
309
+
310
+ try {
311
+ const content = await fs.readFile(file, 'utf8')
312
+ const trimmed = content.trim()
313
+ if (trimmed) sources.push({ source: candidate.source, content: trimmed })
314
+ } catch {
315
+ // optional compatibility source
316
+ }
317
+ }
318
+
319
+ return sources
320
+ }
321
+
322
+ function combineInstructionSources(sources) {
323
+ return sources.map((source) => source.content).join('\n\n') || null
324
+ }
325
+
326
+ function globalInstructionCandidates() {
327
+ const home = os.homedir()
328
+ return [
329
+ { file: path.join(home, '.claude', 'CLAUDE.md'), source: '~/.claude/CLAUDE.md' },
330
+ { file: path.join(home, '.opencode', 'AGENTS.md'), source: '~/.opencode/AGENTS.md' },
331
+ { file: path.join(dataDir, 'AGENTS.md'), source: '~/.quickforge/AGENTS.md' },
332
+ { file: path.join(dataDir, 'agents.md'), source: '~/.quickforge/agents.md' },
333
+ ]
334
+ }
335
+
336
+ function projectInstructionCandidates(workspaceRoot) {
337
+ return [
338
+ { file: path.join(workspaceRoot, 'CLAUDE.md'), source: 'CLAUDE.md' },
339
+ { file: path.join(workspaceRoot, 'AGENTS.md'), source: 'AGENTS.md' },
340
+ { file: path.join(workspaceRoot, 'agents.md'), source: 'agents.md' },
341
+ { file: path.join(workspaceRoot, '.opencode', 'AGENTS.md'), source: '.opencode/AGENTS.md' },
342
+ { file: path.join(workspaceRoot, '.quickforge', 'AGENTS.md'), source: '.quickforge/AGENTS.md' },
343
+ ]
344
+ }
345
+
301
346
  export async function buildInstructionsPayload(projectId) {
302
347
  const config = await readProjectConfig()
303
- let projectInstructions = null
348
+ let projectInstructionSources = []
304
349
  let project = projectId ? config.projects.find((item) => item.id === projectId) ?? null : null
305
350
 
306
351
  if (projectId) {
307
352
  try {
308
353
  const context = await projectContextFromId(projectId)
309
354
  project = context.project
310
- projectInstructions = await readInstructionsFile(path.join(context.workspaceRoot, 'AGENTS.md'))
355
+ projectInstructionSources = await readInstructionSources(projectInstructionCandidates(context.workspaceRoot))
311
356
  } catch {
312
357
  // project not found or inaccessible — leave projectInstructions null
313
358
  }
314
359
  }
315
360
 
316
- const globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
361
+ const globalInstructionSources = await readInstructionSources(globalInstructionCandidates())
362
+ const globalInstructions = combineInstructionSources(globalInstructionSources)
363
+ const projectInstructions = combineInstructionSources(projectInstructionSources)
317
364
  const globalSkills = await loadSelectedGlobalSkills(config.globalSkills)
318
- const projectSkills = project?.skills && project?.path
365
+ const projectSkills = project?.path
319
366
  ? await loadSelectedProjectSkills(project.skills, project.path)
320
367
  : []
321
368
  const activeSkills = mergeSkills(globalSkills, projectSkills)
@@ -331,6 +378,8 @@ export async function buildInstructionsPayload(projectId) {
331
378
  : null,
332
379
  global: globalInstructions,
333
380
  project: projectInstructions,
381
+ globalSources: globalInstructionSources,
382
+ projectSources: projectInstructionSources,
334
383
  globalSkills: globalSkills.map(stripRuntimeFields),
335
384
  projectSkills: projectSkills.map(stripRuntimeFields),
336
385
  skills: activeSkills.map(stripRuntimeFields),
@@ -23,7 +23,6 @@ import {
23
23
  approveAutoCompact,
24
24
  rejectAutoCompact,
25
25
  abortToolCall,
26
- replaceSessionMessages,
27
26
  rollbackSessionMessages,
28
27
  continueSession,
29
28
  agentEvents,
@@ -83,7 +82,7 @@ export async function handleAgentApi(req, res, url) {
83
82
  error.statusCode = 400
84
83
  throw error
85
84
  }
86
- const result = await runPrompt(sessionId, message)
85
+ const result = await runPrompt(sessionId, message, body?.selectedCapabilities)
87
86
  sendJson(res, 200, result)
88
87
  return
89
88
  }
@@ -169,20 +168,6 @@ export async function handleAgentApi(req, res, url) {
169
168
  return
170
169
  }
171
170
 
172
- // POST /api/agents/:sessionId/messages — replace session messages (legacy rollback/sync)
173
- if (req.method === 'POST' && subPath === 'messages') {
174
- const body = await readJsonBody(req)
175
- const messages = body?.messages
176
- if (!Array.isArray(messages)) {
177
- const error = new Error('Missing messages array in request body')
178
- error.statusCode = 400
179
- throw error
180
- }
181
- const state = await replaceSessionMessages(sessionId, messages)
182
- sendJson(res, 200, { ok: true, messages: state?.messages })
183
- return
184
- }
185
-
186
171
  // POST /api/agents/:sessionId/rollback — roll back from a message index on the authoritative server state
187
172
  if (req.method === 'POST' && subPath === 'rollback') {
188
173
  const body = await readJsonBody(req)
@@ -0,0 +1,63 @@
1
+ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
+ import { refreshAllSessionTools } from '../agent-manager.mjs'
3
+ import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
4
+ import { getPluginStatus, refreshPlugins, setPluginConfig, setPluginEnabled } from '../plugins/registry.mjs'
5
+
6
+ async function activeProjectContext() {
7
+ const config = await readProjectConfig()
8
+ const activeProject = config.projects.find((project) => project.id === config.activeProjectId) || config.projects[0]
9
+ if (!activeProject?.id) return null
10
+ return projectContextFromId(activeProject.id)
11
+ }
12
+
13
+ async function refreshPluginsAndAgentTools() {
14
+ const projectContext = await activeProjectContext()
15
+ await refreshPlugins(projectContext)
16
+ const refreshedSessions = await refreshAllSessionTools()
17
+ return { ...(await getPluginStatus(projectContext)), refreshedSessions }
18
+ }
19
+
20
+ export async function handlePluginsApi(req, res, url) {
21
+ const parts = url.pathname.split('/').filter(Boolean)
22
+
23
+ if (req.method === 'GET' && url.pathname === '/api/plugins') {
24
+ sendJson(res, 200, await getPluginStatus(await activeProjectContext()))
25
+ return
26
+ }
27
+
28
+ if (req.method === 'POST' && url.pathname === '/api/plugins/reload') {
29
+ sendJson(res, 200, await refreshPluginsAndAgentTools())
30
+ return
31
+ }
32
+
33
+ if (req.method === 'GET' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2]) {
34
+ const name = decodeSegment(parts[2])
35
+ const status = await getPluginStatus(await activeProjectContext())
36
+ const plugin = status.plugins.find((item) => item.name === name)
37
+ if (!plugin) {
38
+ const error = new Error(`Unknown plugin: ${name}`)
39
+ error.statusCode = 404
40
+ throw error
41
+ }
42
+ sendJson(res, 200, { ...status, plugin })
43
+ return
44
+ }
45
+
46
+ if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'enabled') {
47
+ const body = await readJsonBody(req)
48
+ await setPluginEnabled(decodeSegment(parts[2]), body?.enabled === true)
49
+ sendJson(res, 200, await refreshPluginsAndAgentTools())
50
+ return
51
+ }
52
+
53
+ if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'config') {
54
+ const body = await readJsonBody(req)
55
+ await setPluginConfig(decodeSegment(parts[2]), body?.config || body || {})
56
+ sendJson(res, 200, await refreshPluginsAndAgentTools())
57
+ return
58
+ }
59
+
60
+ const error = new Error('Not found')
61
+ error.statusCode = 404
62
+ throw error
63
+ }
@@ -29,6 +29,8 @@ export async function handleProjectApi(req, res, url) {
29
29
  allowEdit: command.allowEdit,
30
30
  allowCommands: command.allowCommands,
31
31
  relativePath: command.relativePath,
32
+ source: command.source,
33
+ pluginName: command.pluginName,
32
34
  })),
33
35
  })
34
36
  return