@geekbeer/minion 3.16.1 → 3.22.0
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.
- package/core/lib/dag-step-poller.js +158 -6
- package/core/lib/llm-checker.js +4 -7
- package/core/lib/template-expander.js +21 -18
- package/core/llm-dispatch/mcp-server.js +185 -0
- package/core/llm-dispatch/session-pool.js +97 -0
- package/core/llm-plugins/claude/index.js +151 -0
- package/core/llm-plugins/claude/stream.js +166 -0
- package/core/llm-plugins/codex/index.js +161 -0
- package/core/llm-plugins/gemini/index.js +104 -0
- package/core/llm-plugins/lib/active.js +23 -0
- package/core/llm-plugins/lib/mcp-registration.js +132 -0
- package/core/llm-plugins/lib/skill-dirs.js +67 -0
- package/core/llm-plugins/lib/spawn-helper.js +88 -0
- package/core/llm-plugins/registry.js +168 -0
- package/core/llm-plugins/types.js +85 -0
- package/core/routes/llm.js +89 -0
- package/core/routes/skills.js +112 -57
- package/docs/api-reference.md +439 -0
- package/docs/task-guides.md +220 -0
- package/linux/bin/hq +168 -15
- package/linux/minion-cli.sh +14 -0
- package/linux/routes/chat.js +121 -2
- package/linux/routine-runner.js +22 -7
- package/linux/server.js +2 -0
- package/linux/workflow-runner.js +26 -8
- package/package.json +1 -1
- package/rules/core.md +13 -7
- package/win/bin/hq.ps1 +155 -27
- package/win/routes/chat.js +115 -2
- package/win/routine-runner.js +20 -6
- package/win/server.js +2 -0
- package/win/workflow-runner.js +20 -7
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server registration helpers.
|
|
3
|
+
*
|
|
4
|
+
* Each LLM CLI stores MCP server configuration in a different file with a
|
|
5
|
+
* different format. This module hides that variation: callers ask a plugin to
|
|
6
|
+
* register a server, and the plugin (or a fallback here) writes to the
|
|
7
|
+
* appropriate location.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { config } = require('../../config')
|
|
13
|
+
|
|
14
|
+
const DISPATCH_SERVER_NAME = 'minion-llm-dispatch'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the spec for the dispatch MCP server used by Primary to reach children.
|
|
18
|
+
* Plugins register this via {@link registerDispatchServer}.
|
|
19
|
+
*/
|
|
20
|
+
function buildDispatchServerSpec() {
|
|
21
|
+
const serverScript = path.join(__dirname, '..', '..', 'llm-dispatch', 'mcp-server.js')
|
|
22
|
+
return {
|
|
23
|
+
command: 'node',
|
|
24
|
+
args: [serverScript],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSON-config MCP registration (used by Claude Code, Gemini CLI).
|
|
30
|
+
* Reads the target file, merges in the server entry under `mcpServers`,
|
|
31
|
+
* and writes back atomically.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} configPath
|
|
34
|
+
* @param {string} serverName
|
|
35
|
+
* @param {{ command: string, args: string[] }} spec
|
|
36
|
+
*/
|
|
37
|
+
function writeJsonMcpEntry(configPath, serverName, spec) {
|
|
38
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
39
|
+
let current = {}
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
42
|
+
current = JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
// New file or unparseable — start fresh
|
|
45
|
+
}
|
|
46
|
+
if (!current.mcpServers || typeof current.mcpServers !== 'object') {
|
|
47
|
+
current.mcpServers = {}
|
|
48
|
+
}
|
|
49
|
+
current.mcpServers[serverName] = spec
|
|
50
|
+
const tmp = `${configPath}.tmp`
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
|
|
52
|
+
fs.renameSync(tmp, configPath)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a server entry from a JSON MCP config.
|
|
57
|
+
*/
|
|
58
|
+
function removeJsonMcpEntry(configPath, serverName) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
61
|
+
const current = JSON.parse(raw)
|
|
62
|
+
if (current?.mcpServers && current.mcpServers[serverName]) {
|
|
63
|
+
delete current.mcpServers[serverName]
|
|
64
|
+
const tmp = `${configPath}.tmp`
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
|
|
66
|
+
fs.renameSync(tmp, configPath)
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// File missing or unparseable — nothing to remove
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register the dispatch MCP server with the given Primary plugin.
|
|
75
|
+
* No-op if the plugin does not implement addMcpServer() or when we don't
|
|
76
|
+
* know its MCP config location.
|
|
77
|
+
*
|
|
78
|
+
* @param {import('../types').LlmPlugin} plugin
|
|
79
|
+
*/
|
|
80
|
+
function registerDispatchServer(plugin) {
|
|
81
|
+
const spec = buildDispatchServerSpec()
|
|
82
|
+
if (typeof plugin.addMcpServer === 'function') {
|
|
83
|
+
plugin.addMcpServer(DISPATCH_SERVER_NAME, spec)
|
|
84
|
+
return { registered: true, method: 'plugin' }
|
|
85
|
+
}
|
|
86
|
+
// Fallback: well-known locations by plugin name
|
|
87
|
+
const fallbackPath = defaultMcpConfigPath(plugin.name)
|
|
88
|
+
if (fallbackPath) {
|
|
89
|
+
writeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME, spec)
|
|
90
|
+
return { registered: true, method: 'fallback', path: fallbackPath }
|
|
91
|
+
}
|
|
92
|
+
return { registered: false }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Unregister the dispatch MCP server from all known plugin MCP configs.
|
|
97
|
+
* Used when Primary is cleared.
|
|
98
|
+
*/
|
|
99
|
+
function unregisterDispatchServerFromAll(plugins) {
|
|
100
|
+
for (const p of plugins) {
|
|
101
|
+
if (typeof p.removeMcpServer === 'function') {
|
|
102
|
+
try { p.removeMcpServer(DISPATCH_SERVER_NAME) } catch { /* ignore */ }
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const fallbackPath = defaultMcpConfigPath(p.name)
|
|
106
|
+
if (fallbackPath) removeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Best-known MCP config path for a given plugin name. Returns null if unknown. */
|
|
111
|
+
function defaultMcpConfigPath(pluginName) {
|
|
112
|
+
switch (pluginName) {
|
|
113
|
+
case 'claude':
|
|
114
|
+
// Claude Code reads ~/.mcp.json (global) and project-local .mcp.json
|
|
115
|
+
return path.join(config.HOME_DIR, '.mcp.json')
|
|
116
|
+
case 'gemini':
|
|
117
|
+
// Gemini CLI settings include mcpServers field
|
|
118
|
+
return path.join(config.HOME_DIR, '.gemini', 'settings.json')
|
|
119
|
+
default:
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
DISPATCH_SERVER_NAME,
|
|
126
|
+
buildDispatchServerSpec,
|
|
127
|
+
writeJsonMcpEntry,
|
|
128
|
+
removeJsonMcpEntry,
|
|
129
|
+
registerDispatchServer,
|
|
130
|
+
unregisterDispatchServerFromAll,
|
|
131
|
+
defaultMcpConfigPath,
|
|
132
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve skill directory paths across enabled LLM plugins.
|
|
3
|
+
*
|
|
4
|
+
* Each LLM CLI loads skills from its own directory:
|
|
5
|
+
* Claude → ~/.claude/skills/
|
|
6
|
+
* Gemini → ~/.gemini/skills/
|
|
7
|
+
* Codex → ~/.codex/skills/
|
|
8
|
+
*
|
|
9
|
+
* To make skill deployment LLM-agnostic, we write each skill to all enabled
|
|
10
|
+
* plugins' skill directories. Reads/deletes operate on the union.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { config } = require('../../config')
|
|
15
|
+
const { readConfig, loadAllPlugins } = require('../registry')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return the canonical skill directory paths for all enabled plugins.
|
|
19
|
+
* Falls back to ~/.claude/skills/ when the plugin system is not configured
|
|
20
|
+
* (preserves backward compatibility with the LLM_COMMAND era).
|
|
21
|
+
*
|
|
22
|
+
* @returns {string[]} Absolute directory paths
|
|
23
|
+
*/
|
|
24
|
+
function getActiveSkillDirs() {
|
|
25
|
+
const cfg = readConfig()
|
|
26
|
+
const enabled = new Set(cfg.enabled)
|
|
27
|
+
|
|
28
|
+
if (enabled.size === 0) {
|
|
29
|
+
return [path.join(config.HOME_DIR, '.claude', 'skills')]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plugins = loadAllPlugins()
|
|
33
|
+
const dirs = []
|
|
34
|
+
for (const name of enabled) {
|
|
35
|
+
const plugin = plugins.get(name)
|
|
36
|
+
if (plugin && typeof plugin.getSkillsDir === 'function') {
|
|
37
|
+
dirs.push(plugin.getSkillsDir())
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Safety: if no enabled plugin reported a dir, default to claude
|
|
41
|
+
if (dirs.length === 0) {
|
|
42
|
+
dirs.push(path.join(config.HOME_DIR, '.claude', 'skills'))
|
|
43
|
+
}
|
|
44
|
+
return dirs
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the primary plugin's skill directory (used as the canonical
|
|
49
|
+
* read source when listing or pushing skills back to HQ).
|
|
50
|
+
*
|
|
51
|
+
* @returns {string} Absolute directory path
|
|
52
|
+
*/
|
|
53
|
+
function getPrimarySkillDir() {
|
|
54
|
+
const cfg = readConfig()
|
|
55
|
+
if (cfg.primary) {
|
|
56
|
+
const plugins = loadAllPlugins()
|
|
57
|
+
const plugin = plugins.get(cfg.primary)
|
|
58
|
+
if (plugin && typeof plugin.getSkillsDir === 'function') {
|
|
59
|
+
return plugin.getSkillsDir()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Fallback: first enabled plugin's dir, else claude
|
|
63
|
+
const dirs = getActiveSkillDirs()
|
|
64
|
+
return dirs[0]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { getActiveSkillDirs, getPrimarySkillDir }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for plugin implementations that wrap a local LLM CLI.
|
|
3
|
+
*
|
|
4
|
+
* The key goal here is to NEVER pass the prompt through shell string
|
|
5
|
+
* interpolation. Prompts go through stdin or argv arrays — this avoids the
|
|
6
|
+
* entire class of quote-escaping bugs that plagued LLM_COMMAND.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { spawn } = require('child_process')
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { buildExtendedPath, IS_WINDOWS } = require('../../lib/platform')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a binary name to an absolute path if installed in ~/.local/bin,
|
|
16
|
+
* otherwise return the bare name (PATH lookup).
|
|
17
|
+
*/
|
|
18
|
+
function resolveBinary(binaryName, homeDir) {
|
|
19
|
+
const candidate = path.join(homeDir, '.local', 'bin', binaryName)
|
|
20
|
+
if (!IS_WINDOWS && fs.existsSync(candidate)) return candidate
|
|
21
|
+
return binaryName
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Spawn a CLI, pass the prompt via stdin, collect stdout/stderr, resolve with
|
|
26
|
+
* the full output. Never uses shell interpolation.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} opts
|
|
29
|
+
* @param {string} opts.binary
|
|
30
|
+
* @param {string[]} opts.args
|
|
31
|
+
* @param {string} opts.prompt
|
|
32
|
+
* @param {string} opts.homeDir
|
|
33
|
+
* @param {Object} [opts.env]
|
|
34
|
+
* @param {number} [opts.timeoutMs]
|
|
35
|
+
* @returns {Promise<{ stdout: string, stderr: string, code: number, durationMs: number }>}
|
|
36
|
+
*/
|
|
37
|
+
function spawnWithStdin({ binary, args, prompt, homeDir, env = {}, timeoutMs = 30 * 60 * 1000 }) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const started = Date.now()
|
|
40
|
+
const child = spawn(binary, args, {
|
|
41
|
+
cwd: homeDir,
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
HOME: homeDir,
|
|
46
|
+
...(IS_WINDOWS && { USERPROFILE: homeDir }),
|
|
47
|
+
PATH: buildExtendedPath(homeDir),
|
|
48
|
+
...env,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
let stdout = ''
|
|
53
|
+
let stderr = ''
|
|
54
|
+
let timedOut = false
|
|
55
|
+
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
timedOut = true
|
|
58
|
+
child.kill('SIGTERM')
|
|
59
|
+
}, timeoutMs)
|
|
60
|
+
|
|
61
|
+
child.stdout.on('data', d => { stdout += d.toString('utf-8') })
|
|
62
|
+
child.stderr.on('data', d => { stderr += d.toString('utf-8') })
|
|
63
|
+
|
|
64
|
+
child.on('error', err => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
reject(err)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
child.on('close', code => {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
if (timedOut) {
|
|
72
|
+
reject(new Error(`Plugin invocation timed out after ${timeoutMs}ms`))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
resolve({ stdout, stderr, code: code ?? -1, durationMs: Date.now() - started })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
child.stdin.write(prompt)
|
|
80
|
+
child.stdin.end()
|
|
81
|
+
} catch (err) {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
reject(err)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { resolveBinary, spawnWithStdin }
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Plugin Registry
|
|
3
|
+
*
|
|
4
|
+
* Loads builtin plugins (claude, gemini, codex) and any user plugins installed
|
|
5
|
+
* under ~/minion/llm/plugins/<name>/index.js. Persists primary/enabled selection
|
|
6
|
+
* in ~/minion/llm/config.json, read from file on every access (no env var
|
|
7
|
+
* caching — avoids the supervisord quote-corruption class of bug).
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally isolated from the existing LLM_COMMAND code path.
|
|
10
|
+
* It is a no-op at runtime unless a plugin config.json exists with a `primary`
|
|
11
|
+
* field set.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs')
|
|
15
|
+
const path = require('path')
|
|
16
|
+
const { config } = require('../config')
|
|
17
|
+
|
|
18
|
+
const BUILTIN_NAMES = ['claude', 'gemini', 'codex']
|
|
19
|
+
|
|
20
|
+
/** Resolve the plugin root directory for this minion. */
|
|
21
|
+
function getPluginRoot() {
|
|
22
|
+
return path.join(config.HOME_DIR, 'minion', 'llm')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve the plugin config.json path. */
|
|
26
|
+
function getConfigPath() {
|
|
27
|
+
return path.join(getPluginRoot(), 'config.json')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve the user plugins directory. */
|
|
31
|
+
function getUserPluginsDir() {
|
|
32
|
+
return path.join(getPluginRoot(), 'plugins')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the plugin system config.
|
|
37
|
+
* @returns {{ primary: string | null, enabled: string[] }}
|
|
38
|
+
*/
|
|
39
|
+
function readConfig() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(getConfigPath(), 'utf-8')
|
|
42
|
+
const parsed = JSON.parse(raw)
|
|
43
|
+
return {
|
|
44
|
+
primary: typeof parsed.primary === 'string' ? parsed.primary : null,
|
|
45
|
+
enabled: Array.isArray(parsed.enabled) ? parsed.enabled.filter(n => typeof n === 'string') : [],
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return { primary: null, enabled: [] }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Persist the plugin system config atomically.
|
|
54
|
+
* @param {{ primary: string | null, enabled: string[] }} cfg
|
|
55
|
+
*/
|
|
56
|
+
function writeConfig(cfg) {
|
|
57
|
+
const dir = getPluginRoot()
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
59
|
+
const payload = {
|
|
60
|
+
primary: cfg.primary ?? null,
|
|
61
|
+
enabled: Array.isArray(cfg.enabled) ? cfg.enabled : [],
|
|
62
|
+
}
|
|
63
|
+
const target = getConfigPath()
|
|
64
|
+
const tmp = `${target}.tmp`
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf-8')
|
|
66
|
+
fs.renameSync(tmp, target)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {import('./types').LlmPlugin | null} */
|
|
70
|
+
function tryLoad(modulePath) {
|
|
71
|
+
try {
|
|
72
|
+
// Clear require cache so hot-added plugins are picked up on reload.
|
|
73
|
+
delete require.cache[require.resolve(modulePath)]
|
|
74
|
+
const mod = require(modulePath)
|
|
75
|
+
const plugin = mod && mod.plugin ? mod.plugin : mod
|
|
76
|
+
if (!plugin || typeof plugin.name !== 'string') return null
|
|
77
|
+
return plugin
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[LlmPlugin] Failed to load ${modulePath}: ${err.message}`)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Discover and load all available plugins (builtins + user-installed).
|
|
86
|
+
* @returns {Map<string, import('./types').LlmPlugin>}
|
|
87
|
+
*/
|
|
88
|
+
function loadAllPlugins() {
|
|
89
|
+
const plugins = new Map()
|
|
90
|
+
|
|
91
|
+
for (const name of BUILTIN_NAMES) {
|
|
92
|
+
const p = tryLoad(path.join(__dirname, name))
|
|
93
|
+
if (p) plugins.set(p.name, p)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const userDir = getUserPluginsDir()
|
|
97
|
+
try {
|
|
98
|
+
const entries = fs.readdirSync(userDir, { withFileTypes: true })
|
|
99
|
+
for (const ent of entries) {
|
|
100
|
+
if (!ent.isDirectory()) continue
|
|
101
|
+
const p = tryLoad(path.join(userDir, ent.name))
|
|
102
|
+
if (p) plugins.set(p.name, p)
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// No user plugins directory — not an error
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return plugins
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check whether the plugin system is enabled (opt-in).
|
|
113
|
+
* Returns false when no config.json exists or primary is unset.
|
|
114
|
+
*/
|
|
115
|
+
function isEnabled() {
|
|
116
|
+
const cfg = readConfig()
|
|
117
|
+
return !!cfg.primary
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* List all available plugins with runtime status.
|
|
122
|
+
*/
|
|
123
|
+
async function listPlugins() {
|
|
124
|
+
const cfg = readConfig()
|
|
125
|
+
const plugins = loadAllPlugins()
|
|
126
|
+
const out = []
|
|
127
|
+
for (const p of plugins.values()) {
|
|
128
|
+
let authenticated = false
|
|
129
|
+
try {
|
|
130
|
+
authenticated = await p.isAuthenticated()
|
|
131
|
+
} catch {
|
|
132
|
+
// Treat as not authenticated
|
|
133
|
+
}
|
|
134
|
+
out.push({
|
|
135
|
+
name: p.name,
|
|
136
|
+
displayName: p.displayName,
|
|
137
|
+
capabilities: p.capabilities,
|
|
138
|
+
authenticated,
|
|
139
|
+
builtin: BUILTIN_NAMES.includes(p.name),
|
|
140
|
+
enabled: cfg.enabled.includes(p.name),
|
|
141
|
+
primary: cfg.primary === p.name,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve a plugin by name.
|
|
149
|
+
* @param {string} name
|
|
150
|
+
* @returns {import('./types').LlmPlugin | null}
|
|
151
|
+
*/
|
|
152
|
+
function getPlugin(name) {
|
|
153
|
+
const plugins = loadAllPlugins()
|
|
154
|
+
return plugins.get(name) ?? null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
BUILTIN_NAMES,
|
|
159
|
+
getPluginRoot,
|
|
160
|
+
getConfigPath,
|
|
161
|
+
getUserPluginsDir,
|
|
162
|
+
readConfig,
|
|
163
|
+
writeConfig,
|
|
164
|
+
loadAllPlugins,
|
|
165
|
+
listPlugins,
|
|
166
|
+
getPlugin,
|
|
167
|
+
isEnabled,
|
|
168
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Plugin System — Type definitions (JSDoc)
|
|
3
|
+
*
|
|
4
|
+
* Every LLM plugin must implement the LlmPlugin contract. Plugins can act as
|
|
5
|
+
* either the Primary (user-facing) session or as a Child (specialist) session
|
|
6
|
+
* dispatched from the Primary via the dispatch MCP server.
|
|
7
|
+
*
|
|
8
|
+
* See: packages/docs-internal/src/content/docs/design/llm-plugin-system.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} LlmCapabilities
|
|
13
|
+
* @property {boolean} toolUse Can act as Primary (dispatches to children)
|
|
14
|
+
* @property {boolean} streaming Supports SSE-style streaming output
|
|
15
|
+
* @property {boolean} vision Accepts image inputs
|
|
16
|
+
* @property {boolean} imageGeneration Produces image outputs
|
|
17
|
+
* @property {boolean} sessionResume Supports resuming an existing session
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} LlmAttachment
|
|
22
|
+
* @property {string} path
|
|
23
|
+
* @property {string} mimeType
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} LlmInput
|
|
28
|
+
* @property {string} prompt
|
|
29
|
+
* @property {string} [systemPrompt]
|
|
30
|
+
* @property {LlmAttachment[]} [attachments]
|
|
31
|
+
* @property {string} [model]
|
|
32
|
+
* @property {string[]} [mcpServers] Primary only: MCP servers to wire up
|
|
33
|
+
* @property {number} [timeoutMs]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} LlmOutputFile
|
|
38
|
+
* @property {string} path Absolute path on minion FS
|
|
39
|
+
* @property {string} mimeType
|
|
40
|
+
* @property {string} [purpose] e.g. "hero_image", "component_source"
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} LlmOutputMetadata
|
|
45
|
+
* @property {string} [sessionId]
|
|
46
|
+
* @property {number} durationMs
|
|
47
|
+
* @property {{ input: number, output: number }} [tokensUsed]
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} LlmOutputError
|
|
52
|
+
* @property {string} code
|
|
53
|
+
* @property {string} message
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {Object} LlmOutput
|
|
58
|
+
* @property {string} text
|
|
59
|
+
* @property {LlmOutputFile[]} files
|
|
60
|
+
* @property {LlmOutputMetadata} metadata
|
|
61
|
+
* @property {LlmOutputError} [error]
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {Object} LlmEvent
|
|
66
|
+
* @property {string} type "text" | "tool_use" | "tool_result" | "done" | "error"
|
|
67
|
+
* @property {string} [text]
|
|
68
|
+
* @property {*} [data]
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {Object} LlmPlugin
|
|
73
|
+
* @property {string} name Unique identifier (e.g. "claude")
|
|
74
|
+
* @property {string} displayName Human-readable name
|
|
75
|
+
* @property {LlmCapabilities} capabilities
|
|
76
|
+
* @property {() => Promise<boolean>} isAuthenticated
|
|
77
|
+
* @property {(input: LlmInput) => Promise<LlmOutput>} invoke
|
|
78
|
+
* @property {(input: LlmInput, onEvent: (e: LlmEvent) => void, ctx?: any) => Promise<LlmOutput>} [stream]
|
|
79
|
+
* @property {(sessionId: string, input: LlmInput) => Promise<LlmOutput>} [resume]
|
|
80
|
+
* @property {(opts: { promptFile: string, model?: string }) => string} [buildShellInvocation] Build a shell command that runs this LLM. The prompt is passed via file redirection to avoid shell quoting issues. Used by workflow/routine runners.
|
|
81
|
+
* @property {() => string} [getSkillsDir] Absolute path to the skills directory for this plugin (e.g. ~/.claude/skills)
|
|
82
|
+
* @property {(skillName: string, args?: string[]) => string} [formatSkillInvocation] Render a skill invocation in the syntax this plugin understands (e.g. "/skill-name" for Claude/Codex, natural language for Gemini)
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
module.exports = {}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM plugin management routes (shared between Linux and Windows)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/llm/plugins - List available plugins with capabilities & auth status
|
|
5
|
+
* GET /api/llm/config - Get current primary/enabled selection
|
|
6
|
+
* PUT /api/llm/config - Update primary/enabled selection
|
|
7
|
+
*
|
|
8
|
+
* All endpoints require Bearer token authentication.
|
|
9
|
+
*
|
|
10
|
+
* This system is opt-in: when no primary is set, the existing LLM_COMMAND
|
|
11
|
+
* code path continues to operate unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { verifyToken } = require('../lib/auth')
|
|
15
|
+
const { listPlugins, readConfig, writeConfig, loadAllPlugins, getPlugin } = require('../llm-plugins/registry')
|
|
16
|
+
const { registerDispatchServer, unregisterDispatchServerFromAll } = require('../llm-plugins/lib/mcp-registration')
|
|
17
|
+
|
|
18
|
+
function llmRoutes(fastify, _opts, done) {
|
|
19
|
+
fastify.get('/api/llm/plugins', async (request, reply) => {
|
|
20
|
+
if (!verifyToken(request)) {
|
|
21
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
22
|
+
}
|
|
23
|
+
const plugins = await listPlugins()
|
|
24
|
+
return { success: true, plugins }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
fastify.get('/api/llm/config', async (request, reply) => {
|
|
28
|
+
if (!verifyToken(request)) {
|
|
29
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
30
|
+
}
|
|
31
|
+
return { success: true, config: readConfig() }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
fastify.put('/api/llm/config', async (request, reply) => {
|
|
35
|
+
if (!verifyToken(request)) {
|
|
36
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
37
|
+
}
|
|
38
|
+
const body = request.body || {}
|
|
39
|
+
const primary = body.primary === null ? null : (typeof body.primary === 'string' ? body.primary : undefined)
|
|
40
|
+
const enabled = Array.isArray(body.enabled) ? body.enabled.filter(n => typeof n === 'string') : undefined
|
|
41
|
+
|
|
42
|
+
if (primary === undefined && enabled === undefined) {
|
|
43
|
+
return reply.code(400).send({ error: 'primary or enabled is required' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const current = readConfig()
|
|
47
|
+
const next = {
|
|
48
|
+
primary: primary === undefined ? current.primary : primary,
|
|
49
|
+
enabled: enabled === undefined ? current.enabled : enabled,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (next.primary !== null) {
|
|
53
|
+
const plugins = loadAllPlugins()
|
|
54
|
+
if (!plugins.has(next.primary)) {
|
|
55
|
+
return reply.code(400).send({ error: `Unknown plugin: ${next.primary}` })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
writeConfig(next)
|
|
61
|
+
|
|
62
|
+
// Sync MCP dispatch server registration with the new primary.
|
|
63
|
+
try {
|
|
64
|
+
const allPlugins = [...loadAllPlugins().values()]
|
|
65
|
+
if (next.primary) {
|
|
66
|
+
const primaryPlugin = getPlugin(next.primary)
|
|
67
|
+
if (primaryPlugin) {
|
|
68
|
+
// Clear from non-primary plugins first so we don't leave stale entries.
|
|
69
|
+
unregisterDispatchServerFromAll(allPlugins.filter(p => p.name !== next.primary))
|
|
70
|
+
registerDispatchServer(primaryPlugin)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
unregisterDispatchServerFromAll(allPlugins)
|
|
74
|
+
}
|
|
75
|
+
} catch (regErr) {
|
|
76
|
+
console.error('[LLM] MCP server registration failed:', regErr.message)
|
|
77
|
+
// Non-fatal — config is still saved
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { success: true, config: next }
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return reply.code(500).send({ error: `Failed to write config: ${err.message}` })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
done()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { llmRoutes }
|