@geekbeer/minion 3.17.0 → 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.
@@ -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 }
@@ -19,6 +19,8 @@ const api = require('../api')
19
19
  const { config, isHqConfigured } = require('../config')
20
20
  const executionStore = require('../stores/execution-store')
21
21
  const logManager = require('../lib/log-manager')
22
+ const { reportDagNodeComplete } = require('../lib/dag-node-executor')
23
+ const { getActiveSkillDirs, getPrimarySkillDir } = require('../llm-plugins/lib/skill-dirs')
22
24
 
23
25
  /**
24
26
  * Parse YAML frontmatter from SKILL.md content
@@ -40,8 +42,12 @@ function parseFrontmatter(content) {
40
42
  }
41
43
 
42
44
  /**
43
- * Write a skill to the local ~/.claude/skills/ directory.
44
- * Shared by deploy-skill and skills/fetch endpoints.
45
+ * Write a skill to the local skill directories of all enabled LLM plugins.
46
+ *
47
+ * Multi-plugin distribution: each LLM CLI loads skills from its own dir
48
+ * (e.g. ~/.claude/skills/, ~/.gemini/skills/). The same skill content is
49
+ * written to every enabled plugin's directory so switching Primary doesn't
50
+ * break skill availability.
45
51
  *
46
52
  * @param {string} name - Skill slug (e.g. "execution-report")
47
53
  * @param {object} opts
@@ -49,15 +55,9 @@ function parseFrontmatter(content) {
49
55
  * @param {string} [opts.description] - Skill description for frontmatter
50
56
  * @param {string} [opts.display_name] - Display name for frontmatter
51
57
  * @param {Array<{path: string, content?: string}>} [opts.files] - Skill files from HQ storage
52
- * @returns {Promise<{path: string, files_count: number}>}
58
+ * @returns {Promise<{path: string, paths: string[], files_count: number}>}
53
59
  */
54
60
  async function writeSkillToLocal(name, { content, description, display_name, type, files = [] }) {
55
- const skillDir = path.join(config.HOME_DIR, '.claude', 'skills', name)
56
- const filesDir = path.join(skillDir, 'files')
57
-
58
- await fs.mkdir(skillDir, { recursive: true })
59
- await fs.mkdir(filesDir, { recursive: true })
60
-
61
61
  // If content already has frontmatter, write as-is; otherwise build frontmatter
62
62
  const hasFrontmatter = content.startsWith('---\n')
63
63
  let fileContent
@@ -73,21 +73,30 @@ async function writeSkillToLocal(name, { content, description, display_name, typ
73
73
  fileContent = `---\n${frontmatterLines}\n---\n\n${content}`
74
74
  }
75
75
 
76
- await fs.writeFile(
77
- path.join(skillDir, 'SKILL.md'),
78
- fileContent,
79
- 'utf-8'
80
- )
81
-
82
- // Write skill files
83
- for (const file of files) {
84
- if (file.path && file.content) {
85
- const safeFilename = path.basename(file.path)
86
- await fs.writeFile(path.join(filesDir, safeFilename), file.content, 'utf-8')
76
+ const targetRoots = getActiveSkillDirs()
77
+ const writtenPaths = []
78
+
79
+ for (const root of targetRoots) {
80
+ const skillDir = path.join(root, name)
81
+ const filesDir = path.join(skillDir, 'files')
82
+ await fs.mkdir(skillDir, { recursive: true })
83
+ await fs.mkdir(filesDir, { recursive: true })
84
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), fileContent, 'utf-8')
85
+
86
+ for (const file of files) {
87
+ if (file.path && file.content) {
88
+ const safeFilename = path.basename(file.path)
89
+ await fs.writeFile(path.join(filesDir, safeFilename), file.content, 'utf-8')
90
+ }
87
91
  }
92
+ writtenPaths.push(skillDir)
88
93
  }
89
94
 
90
- return { path: skillDir, files_count: files.length }
95
+ return {
96
+ path: writtenPaths[0],
97
+ paths: writtenPaths,
98
+ files_count: files.length,
99
+ }
91
100
  }
92
101
 
93
102
  /**
@@ -99,8 +108,10 @@ async function writeSkillToLocal(name, { content, description, display_name, typ
99
108
  * @throws {Error} If skill not found locally or HQ rejects
100
109
  */
101
110
  async function pushSkillToHQ(name) {
102
- const homeDir = config.HOME_DIR
103
- const skillDir = path.join(homeDir, '.claude', 'skills', name)
111
+ // Use the Primary plugin's skill directory as the canonical read source.
112
+ // Skills are written identically to every enabled plugin dir, so reading
113
+ // from any one of them yields the same content.
114
+ const skillDir = path.join(getPrimarySkillDir(), name)
104
115
  const skillMdPath = path.join(skillDir, 'SKILL.md')
105
116
 
106
117
  const rawContent = await fs.readFile(skillMdPath, 'utf-8')
@@ -158,34 +169,29 @@ async function skillRoutes(fastify, opts) {
158
169
  console.log('[Skills] Listing deployed skills')
159
170
 
160
171
  try {
161
- const homeDir = config.HOME_DIR
162
- const skillsDir = path.join(homeDir, '.claude', 'skills')
163
-
164
- // Check if skills directory exists
165
- try {
166
- await fs.access(skillsDir)
167
- } catch {
168
- // Directory doesn't exist, return empty list
169
- console.log('[Skills] Skills directory does not exist, returning empty list')
170
- return { success: true, skills: [] }
171
- }
172
-
173
- // Read directory contents
174
- const entries = await fs.readdir(skillsDir, { withFileTypes: true })
175
-
176
- // Filter to only directories that contain SKILL.md
177
- const skills = []
178
- for (const entry of entries) {
179
- if (entry.isDirectory()) {
172
+ // Union of skills across all active plugin skill dirs.
173
+ // Same skill name may exist in multiple dirs (multi-plugin distribution);
174
+ // dedupe via Set.
175
+ const skillsSet = new Set()
176
+ for (const skillsDir of getActiveSkillDirs()) {
177
+ try {
178
+ await fs.access(skillsDir)
179
+ } catch {
180
+ continue
181
+ }
182
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true })
183
+ for (const entry of entries) {
184
+ if (!entry.isDirectory()) continue
180
185
  const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md')
181
186
  try {
182
187
  await fs.access(skillMdPath)
183
- skills.push(entry.name)
188
+ skillsSet.add(entry.name)
184
189
  } catch {
185
- // SKILL.md doesn't exist, skip this directory
190
+ // SKILL.md missing, skip
186
191
  }
187
192
  }
188
193
  }
194
+ const skills = [...skillsSet]
189
195
 
190
196
  console.log(`[Skills] Found ${skills.length} deployed skills: ${skills.join(', ') || '(none)'}`)
191
197
 
@@ -311,16 +317,23 @@ async function skillRoutes(fastify, opts) {
311
317
  return { success: false, error: 'Unauthorized' }
312
318
  }
313
319
 
314
- const { skill_name, execution_id, step_index, workflow_name, role, revision_feedback } = request.body || {}
320
+ const {
321
+ skill_name,
322
+ execution_id,
323
+ step_index,
324
+ workflow_name,
325
+ role,
326
+ revision_feedback,
327
+ dag_node_execution_id,
328
+ } = request.body || {}
315
329
 
316
330
  if (!skill_name) {
317
331
  reply.code(400)
318
332
  return { success: false, error: 'skill_name is required' }
319
333
  }
320
334
 
321
- // Verify skill exists locally
322
- const homeDir = config.HOME_DIR
323
- const skillMdPath = path.join(homeDir, '.claude', 'skills', skill_name, 'SKILL.md')
335
+ // Verify skill exists locally (any plugin's dir is sufficient)
336
+ const skillMdPath = path.join(getPrimarySkillDir(), skill_name, 'SKILL.md')
324
337
  try {
325
338
  await fs.access(skillMdPath)
326
339
  } catch {
@@ -419,6 +432,44 @@ async function skillRoutes(fastify, opts) {
419
432
  console.error(`[Skills] Failed to report step completion: ${hookErr.message}`)
420
433
  }
421
434
  }
435
+
436
+ // Post-execution hook: report DAG node completion to HQ
437
+ if (dag_node_execution_id) {
438
+ try {
439
+ let summary = null
440
+ let details = null
441
+ try {
442
+ const execRecord = await executionStore.getById(effectiveExecutionId)
443
+ if (execRecord) {
444
+ summary = execRecord.summary || null
445
+ details = execRecord.details || null
446
+ }
447
+ } catch (readErr) {
448
+ console.error(`[Skills] Failed to read execution report for DAG node: ${readErr.message}`)
449
+ }
450
+
451
+ await reportDagNodeComplete(
452
+ dag_node_execution_id,
453
+ success ? 'completed' : 'failed',
454
+ summary,
455
+ details
456
+ )
457
+ console.log(`[Skills] DAG node completion reported: ${dag_node_execution_id} → ${success ? 'completed' : 'failed'}`)
458
+ } catch (hookErr) {
459
+ console.error(`[Skills] Failed to report DAG node completion: ${hookErr.message}`)
460
+ }
461
+
462
+ // Clean up ephemeral transform skill directory (created by dag-step-poller)
463
+ if (skill_name.startsWith('_dag_transform_')) {
464
+ for (const root of getActiveSkillDirs()) {
465
+ try {
466
+ await fs.rm(path.join(root, skill_name), { recursive: true, force: true })
467
+ } catch {
468
+ // best-effort cleanup
469
+ }
470
+ }
471
+ }
472
+ }
422
473
  }
423
474
  })()
424
475
 
@@ -458,20 +509,24 @@ async function skillRoutes(fastify, opts) {
458
509
  console.log(`[Skills] Deleting skill: ${name}`)
459
510
 
460
511
  try {
461
- const homeDir = config.HOME_DIR
462
- const skillDir = path.join(homeDir, '.claude', 'skills', name)
512
+ // Delete from every active plugin's skill dir
513
+ let foundAny = false
514
+ for (const root of getActiveSkillDirs()) {
515
+ const skillDir = path.join(root, name)
516
+ try {
517
+ await fs.access(skillDir)
518
+ await fs.rm(skillDir, { recursive: true, force: true })
519
+ foundAny = true
520
+ } catch {
521
+ // not present in this dir
522
+ }
523
+ }
463
524
 
464
- // Check if skill exists
465
- try {
466
- await fs.access(skillDir)
467
- } catch {
525
+ if (!foundAny) {
468
526
  reply.code(404)
469
527
  return { success: false, error: 'Skill not found' }
470
528
  }
471
529
 
472
- // Recursively delete the skill directory
473
- await fs.rm(skillDir, { recursive: true, force: true })
474
-
475
530
  console.log(`[Skills] Skill deleted successfully: ${name}`)
476
531
 
477
532
  return {