@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.
@@ -7,8 +7,6 @@
7
7
  * - Supports concurrent node execution (up to MAX_CONCURRENT)
8
8
  * - Reports completion to /api/dag/minion/node-complete
9
9
  * - Injects input_data into skill context
10
- *
11
- * Feature flag: only starts when DAG_ENGINE_ENABLED=true
12
10
  */
13
11
 
14
12
  const { config, isHqConfigured } = require('../config')
@@ -98,13 +96,24 @@ async function pollOnce() {
98
96
  }
99
97
 
100
98
  /**
101
- * Execute a single pending DAG node:
99
+ * Execute a single pending DAG node.
100
+ * Routes to skill or transform execution based on node_type.
101
+ */
102
+ async function executeNode(node) {
103
+ if (node.node_type === 'transform') {
104
+ return executeTransformNode(node)
105
+ }
106
+ return executeSkillNode(node)
107
+ }
108
+
109
+ /**
110
+ * Execute a skill node:
102
111
  * 1. Claim the node
103
112
  * 2. Fetch the skill from HQ
104
113
  * 3. Run the skill locally (with input_data injected)
105
114
  * 4. Report completion with output_data
106
115
  */
107
- async function executeNode(node) {
116
+ async function executeSkillNode(node) {
108
117
  const {
109
118
  node_execution_id,
110
119
  execution_id,
@@ -119,7 +128,7 @@ async function executeNode(node) {
119
128
  } = node
120
129
 
121
130
  console.log(
122
- `[DagPoller] Executing node "${node_id}" of DAG "${dag_workflow_name}" ` +
131
+ `[DagPoller] Executing skill node "${node_id}" of DAG "${dag_workflow_name}" ` +
123
132
  `(skill: ${resolvedSkillName || skill_version_id}, scope: "${scope_path || 'root'}", role: ${assigned_role})`
124
133
  )
125
134
 
@@ -212,7 +221,7 @@ async function executeNode(node) {
212
221
  // This will be handled by dag-node-executor.js which watches the session.
213
222
 
214
223
  } catch (err) {
215
- console.error(`[DagPoller] Failed to execute node ${node_id}: ${err.message}`)
224
+ console.error(`[DagPoller] Failed to execute skill node ${node_id}: ${err.message}`)
216
225
  try {
217
226
  await reportNodeComplete(node_execution_id, 'failed', null, err.message)
218
227
  } catch {
@@ -221,6 +230,149 @@ async function executeNode(node) {
221
230
  }
222
231
  }
223
232
 
233
+ /**
234
+ * Execute a transform node:
235
+ * 1. Claim the node
236
+ * 2. Create an ephemeral skill from the transform_instruction
237
+ * 3. Run the ephemeral skill via existing /api/skills/run
238
+ * 4. Clean up ephemeral skill directory
239
+ */
240
+ async function executeTransformNode(node) {
241
+ const {
242
+ node_execution_id,
243
+ execution_id,
244
+ dag_workflow_name,
245
+ node_id,
246
+ scope_path,
247
+ assigned_role,
248
+ input_data,
249
+ transform_instruction,
250
+ } = node
251
+
252
+ console.log(
253
+ `[DagPoller] Executing transform node "${node_id}" of DAG "${dag_workflow_name}" ` +
254
+ `(scope: "${scope_path || 'root'}", role: ${assigned_role})`
255
+ )
256
+
257
+ const ephemeralName = `_dag_transform_${node_execution_id.substring(0, 8)}`
258
+ const fs = require('fs').promises
259
+ const path = require('path')
260
+ const { getActiveSkillDirs } = require('../llm-plugins/lib/skill-dirs')
261
+ const skillRoots = getActiveSkillDirs()
262
+ // Write the ephemeral skill to every active plugin's dir so any Primary
263
+ // can find it. Cleanup at the end sweeps the same set.
264
+ const ephemeralSkillDirs = skillRoots.map(root => path.join(root, ephemeralName))
265
+ const skillDir = ephemeralSkillDirs[0] // canonical for downstream paths
266
+
267
+ try {
268
+ // 1. Claim the node
269
+ try {
270
+ await dagRequest('/claim-node', {
271
+ method: 'POST',
272
+ body: JSON.stringify({ node_execution_id }),
273
+ })
274
+ } catch (claimErr) {
275
+ if (claimErr.statusCode === 409) {
276
+ console.log(`[DagPoller] Transform node ${node_id} already claimed, skipping`)
277
+ return
278
+ }
279
+ throw claimErr
280
+ }
281
+
282
+ // 2. Create ephemeral skill from transform_instruction.
283
+ // Write to every active plugin's skill dir so any Primary can find it.
284
+ const skillContent = buildTransformSkillContent(transform_instruction, input_data)
285
+ for (const dir of ephemeralSkillDirs) {
286
+ await fs.mkdir(dir, { recursive: true })
287
+ await fs.writeFile(path.join(dir, 'SKILL.md'), skillContent, 'utf-8')
288
+ }
289
+
290
+ // 3. Run the ephemeral skill
291
+ const runPayload = {
292
+ skill_name: ephemeralName,
293
+ execution_id,
294
+ workflow_name: dag_workflow_name,
295
+ role: assigned_role,
296
+ dag_node_id: node_id,
297
+ dag_input_data: input_data,
298
+ dag_node_execution_id: node_execution_id,
299
+ }
300
+
301
+ const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
302
+ const runResp = await fetch(runUrl, {
303
+ method: 'POST',
304
+ headers: {
305
+ 'Content-Type': 'application/json',
306
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
307
+ },
308
+ body: JSON.stringify(runPayload),
309
+ })
310
+
311
+ if (!runResp.ok) {
312
+ const errData = await runResp.json().catch(() => ({}))
313
+ console.error(`[DagPoller] Transform run failed: ${errData.error || runResp.status}`)
314
+ await reportNodeComplete(
315
+ node_execution_id,
316
+ 'failed',
317
+ null,
318
+ `Failed to start transform: ${errData.error || 'unknown error'}`
319
+ )
320
+ return
321
+ }
322
+
323
+ const runData = await runResp.json()
324
+ console.log(
325
+ `[DagPoller] Transform started for node "${node_id}" ` +
326
+ `(session: ${runData.session_name}). Completion reported by post-execution hook.`
327
+ )
328
+
329
+ } catch (err) {
330
+ console.error(`[DagPoller] Failed to execute transform node ${node_id}: ${err.message}`)
331
+ // Best-effort cleanup on synchronous failure (before skill hand-off).
332
+ // On the success path, cleanup is performed by the post-execution hook
333
+ // in skills.js once the session actually finishes.
334
+ for (const dir of ephemeralSkillDirs) {
335
+ try {
336
+ await fs.rm(dir, { recursive: true, force: true })
337
+ } catch {
338
+ // best-effort
339
+ }
340
+ }
341
+ try {
342
+ await reportNodeComplete(node_execution_id, 'failed', null, err.message)
343
+ } catch {
344
+ // best-effort
345
+ }
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Build SKILL.md content for a transform node's ephemeral skill.
351
+ */
352
+ function buildTransformSkillContent(instruction, inputData) {
353
+ return [
354
+ '---',
355
+ 'name: dag-transform',
356
+ 'description: DAG Transform Node',
357
+ '---',
358
+ '',
359
+ 'You are a data transformation step in a DAG workflow.',
360
+ '',
361
+ '## Input Data',
362
+ '```json',
363
+ JSON.stringify(inputData, null, 2),
364
+ '```',
365
+ '',
366
+ '## Transform Instruction',
367
+ instruction,
368
+ '',
369
+ '## Task',
370
+ 'Apply the transform instruction to the input data above.',
371
+ 'Output the result as a JSON object in an "## Output Data" section with a json code block.',
372
+ 'Do NOT output anything other than the Output Data section.',
373
+ ].join('\n')
374
+ }
375
+
224
376
  /**
225
377
  * Resolve skill_version_id to skill name via HQ API.
226
378
  */
@@ -69,8 +69,10 @@ function isClaudeAuthenticated() {
69
69
  function isGeminiAuthenticated() {
70
70
  if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return true
71
71
 
72
+ // Official @google/gemini-cli stores OAuth credentials at ~/.gemini/oauth_creds.json
73
+ // after `gemini` login. Fall back to ADC locations for gcloud-based setups.
72
74
  const possiblePaths = [
73
- path.join(config.HOME_DIR, '.config', 'gemini'),
75
+ path.join(config.HOME_DIR, '.gemini', 'oauth_creds.json'),
74
76
  path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
75
77
  // Windows-specific locations
76
78
  ...(IS_WINDOWS ? [
@@ -80,12 +82,7 @@ function isGeminiAuthenticated() {
80
82
  for (const p of possiblePaths) {
81
83
  try {
82
84
  if (!fs.existsSync(p)) continue
83
- const stat = fs.statSync(p)
84
- if (stat.isDirectory()) {
85
- if (fs.readdirSync(p).length > 0) return true
86
- } else {
87
- if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
88
- }
85
+ if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
89
86
  } catch {
90
87
  // Ignore
91
88
  }
@@ -44,21 +44,26 @@ async function expandSkillTemplates(skillNames) {
44
44
  // If no variables defined, skip
45
45
  if (Object.keys(minionVars).length === 0) return new Map()
46
46
 
47
+ // originals key: `${root}::${name}` so we restore the right copy in each
48
+ // plugin's skill dir (multi-plugin distribution).
47
49
  const originals = new Map()
48
- const skillsDir = path.join(config.HOME_DIR, '.claude', 'skills')
50
+ const { getActiveSkillDirs } = require('../llm-plugins/lib/skill-dirs')
51
+ const roots = getActiveSkillDirs()
49
52
 
50
- for (const name of skillNames) {
51
- const skillMdPath = path.join(skillsDir, name, 'SKILL.md')
52
- try {
53
- const original = await fs.readFile(skillMdPath, 'utf-8')
54
- const expanded = expandTemplateVars(original, minionVars)
55
- if (expanded !== original) {
56
- originals.set(name, original)
57
- await fs.writeFile(skillMdPath, expanded, 'utf-8')
58
- console.log(`[TemplateExpander] Expanded {{VAR}} in skill: ${name}`)
53
+ for (const root of roots) {
54
+ for (const name of skillNames) {
55
+ const skillMdPath = path.join(root, name, 'SKILL.md')
56
+ try {
57
+ const original = await fs.readFile(skillMdPath, 'utf-8')
58
+ const expanded = expandTemplateVars(original, minionVars)
59
+ if (expanded !== original) {
60
+ originals.set(`${root}::${name}`, { skillMdPath, original })
61
+ await fs.writeFile(skillMdPath, expanded, 'utf-8')
62
+ console.log(`[TemplateExpander] Expanded {{VAR}} in skill: ${name} (${root})`)
63
+ }
64
+ } catch {
65
+ // Skill missing in this dir — skip
59
66
  }
60
- } catch (err) {
61
- console.error(`[TemplateExpander] Failed to expand ${name}: ${err.message}`)
62
67
  }
63
68
  }
64
69
 
@@ -68,16 +73,14 @@ async function expandSkillTemplates(skillNames) {
68
73
  /**
69
74
  * Restore original SKILL.md contents after execution.
70
75
  *
71
- * @param {Map<string, string>} originals - Map from expandSkillTemplates
76
+ * @param {Map<string, {skillMdPath: string, original: string}>} originals - Map from expandSkillTemplates
72
77
  */
73
78
  async function restoreSkillTemplates(originals) {
74
- const skillsDir = path.join(config.HOME_DIR, '.claude', 'skills')
75
- for (const [name, content] of originals) {
76
- const skillMdPath = path.join(skillsDir, name, 'SKILL.md')
79
+ for (const { skillMdPath, original } of originals.values()) {
77
80
  try {
78
- await fs.writeFile(skillMdPath, content, 'utf-8')
81
+ await fs.writeFile(skillMdPath, original, 'utf-8')
79
82
  } catch (err) {
80
- console.error(`[TemplateExpander] Failed to restore ${name}: ${err.message}`)
83
+ console.error(`[TemplateExpander] Failed to restore ${skillMdPath}: ${err.message}`)
81
84
  }
82
85
  }
83
86
  }
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dispatch MCP server (stdio).
4
+ *
5
+ * Primary LLM clients (Claude Code, Codex, etc.) launch this as a child
6
+ * process via ~/.mcp.json. It exposes one MCP tool per enabled child plugin:
7
+ *
8
+ * dispatch_to_<plugin>(prompt, opts)
9
+ *
10
+ * When invoked, the server acquires a slot from the child session pool,
11
+ * calls plugin.invoke() (or plugin.resume() when a prior slot is reused),
12
+ * and returns the standardized LlmOutput as an MCP tool result.
13
+ *
14
+ * Parallel tool calls from the Primary are supported: each concurrent call
15
+ * gets its own slot. The server's lifetime equals the Primary's lifetime
16
+ * (when the Primary exits, the MCP transport closes and this process dies,
17
+ * disposing the pool).
18
+ *
19
+ * A parent_session_id is generated on startup (random UUID). It represents
20
+ * "this Primary session" and scopes the pool.
21
+ *
22
+ * Protocol: JSON-RPC 2.0 over stdio, one message per line.
23
+ */
24
+
25
+ const readline = require('readline')
26
+ const crypto = require('crypto')
27
+ const { loadAllPlugins, readConfig } = require('../llm-plugins/registry')
28
+ const { SessionPool } = require('./session-pool')
29
+
30
+ const PARENT_SESSION_ID = crypto.randomUUID()
31
+ const pool = new SessionPool()
32
+
33
+ const plugins = loadAllPlugins()
34
+ const cfg = readConfig()
35
+
36
+ /** Enabled child plugins (exclude the primary itself to avoid self-dispatch loops). */
37
+ function enabledChildren() {
38
+ const enabled = new Set(cfg.enabled)
39
+ const out = []
40
+ for (const p of plugins.values()) {
41
+ if (!enabled.has(p.name)) continue
42
+ if (p.name === cfg.primary) continue
43
+ out.push(p)
44
+ }
45
+ return out
46
+ }
47
+
48
+ function toolNameFor(pluginName) {
49
+ return `dispatch_to_${pluginName}`
50
+ }
51
+
52
+ function toolsList() {
53
+ return enabledChildren().map(p => ({
54
+ name: toolNameFor(p.name),
55
+ description: `Dispatch a prompt to ${p.displayName}. Returns standardized LlmOutput { text, files, metadata }.`,
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ prompt: { type: 'string', description: 'Prompt text to send to the child LLM.' },
60
+ systemPrompt: { type: 'string', description: 'Optional system prompt.' },
61
+ model: { type: 'string', description: 'Optional model identifier.' },
62
+ continue: {
63
+ type: 'boolean',
64
+ description: 'Reuse the existing child session if one exists. Default true.',
65
+ default: true,
66
+ },
67
+ },
68
+ required: ['prompt'],
69
+ },
70
+ }))
71
+ }
72
+
73
+ async function handleToolCall(name, args) {
74
+ const children = enabledChildren()
75
+ const plugin = children.find(p => toolNameFor(p.name) === name)
76
+ if (!plugin) throw new Error(`Unknown tool: ${name}`)
77
+
78
+ const input = {
79
+ prompt: String(args.prompt || ''),
80
+ systemPrompt: args.systemPrompt,
81
+ model: args.model,
82
+ }
83
+ const continueSession = args.continue !== false
84
+
85
+ const forceFresh = !continueSession || !plugin.capabilities.sessionResume
86
+ const { slot } = pool.acquire(PARENT_SESSION_ID, plugin.name, { forceFresh })
87
+
88
+ let output
89
+ try {
90
+ if (slot.sessionId && continueSession && typeof plugin.resume === 'function') {
91
+ output = await plugin.resume(slot.sessionId, input)
92
+ } else {
93
+ output = await plugin.invoke(input)
94
+ }
95
+ } catch (err) {
96
+ pool.drop(PARENT_SESSION_ID, slot)
97
+ throw err
98
+ }
99
+
100
+ pool.release(slot, output?.metadata?.sessionId)
101
+ return output
102
+ }
103
+
104
+ // --- JSON-RPC transport ------------------------------------------------------
105
+
106
+ function send(msg) {
107
+ process.stdout.write(JSON.stringify(msg) + '\n')
108
+ }
109
+
110
+ function sendResult(id, result) {
111
+ send({ jsonrpc: '2.0', id, result })
112
+ }
113
+
114
+ function sendError(id, code, message) {
115
+ send({ jsonrpc: '2.0', id, error: { code, message } })
116
+ }
117
+
118
+ async function dispatch(msg) {
119
+ const { id, method, params } = msg
120
+ try {
121
+ switch (method) {
122
+ case 'initialize':
123
+ sendResult(id, {
124
+ protocolVersion: '2024-11-05',
125
+ serverInfo: { name: 'minion-llm-dispatch', version: '1.0.0' },
126
+ capabilities: { tools: {} },
127
+ })
128
+ return
129
+
130
+ case 'notifications/initialized':
131
+ // Notification: no response
132
+ return
133
+
134
+ case 'tools/list':
135
+ sendResult(id, { tools: toolsList() })
136
+ return
137
+
138
+ case 'tools/call': {
139
+ const { name, arguments: args } = params || {}
140
+ const output = await handleToolCall(name, args || {})
141
+ sendResult(id, {
142
+ content: [{ type: 'text', text: JSON.stringify(output) }],
143
+ isError: !!output?.error,
144
+ })
145
+ return
146
+ }
147
+
148
+ case 'ping':
149
+ sendResult(id, {})
150
+ return
151
+
152
+ default:
153
+ if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`)
154
+ return
155
+ }
156
+ } catch (err) {
157
+ if (id !== undefined) sendError(id, -32000, err.message || String(err))
158
+ }
159
+ }
160
+
161
+ function start() {
162
+ const rl = readline.createInterface({ input: process.stdin, terminal: false })
163
+ rl.on('line', line => {
164
+ const trimmed = line.trim()
165
+ if (!trimmed) return
166
+ let msg
167
+ try {
168
+ msg = JSON.parse(trimmed)
169
+ } catch {
170
+ // Ignore malformed lines
171
+ return
172
+ }
173
+ dispatch(msg)
174
+ })
175
+ rl.on('close', () => {
176
+ pool.disposeParent(PARENT_SESSION_ID)
177
+ process.exit(0)
178
+ })
179
+ }
180
+
181
+ if (require.main === module) {
182
+ start()
183
+ }
184
+
185
+ module.exports = { start, toolsList, handleToolCall, pool, PARENT_SESSION_ID }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Child session pool for dispatch.
3
+ *
4
+ * Keyed by (parentSessionId, pluginName). When a dispatch arrives while the
5
+ * existing pool entry is in-flight (parallel dispatch), a new slot is created
6
+ * so that the concurrent calls use independent child sessions.
7
+ *
8
+ * Parent termination disposes all slots at once.
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} Slot
13
+ * @property {string} pluginName
14
+ * @property {string|null} sessionId plugin-reported sessionId (for resume)
15
+ * @property {boolean} inFlight
16
+ */
17
+
18
+ class SessionPool {
19
+ constructor() {
20
+ /** @type {Map<string, Slot[]>} parentSessionId -> slots */
21
+ this._slots = new Map()
22
+ }
23
+
24
+ _key(parentSessionId) {
25
+ return String(parentSessionId)
26
+ }
27
+
28
+ /**
29
+ * Acquire a slot for (parent, plugin). Reuses an idle slot if available,
30
+ * otherwise creates a new slot. Marks the slot in-flight.
31
+ * @param {string} parentSessionId
32
+ * @param {string} pluginName
33
+ * @param {{ forceFresh?: boolean }} [opts]
34
+ * @returns {{ slot: Slot, slotIndex: number, slots: Slot[] }}
35
+ */
36
+ acquire(parentSessionId, pluginName, opts = {}) {
37
+ const key = this._key(parentSessionId)
38
+ let slots = this._slots.get(key)
39
+ if (!slots) {
40
+ slots = []
41
+ this._slots.set(key, slots)
42
+ }
43
+
44
+ if (!opts.forceFresh) {
45
+ const idle = slots.find(s => s.pluginName === pluginName && !s.inFlight)
46
+ if (idle) {
47
+ idle.inFlight = true
48
+ return { slot: idle, slotIndex: slots.indexOf(idle), slots }
49
+ }
50
+ }
51
+
52
+ const slot = { pluginName, sessionId: null, inFlight: true }
53
+ slots.push(slot)
54
+ return { slot, slotIndex: slots.length - 1, slots }
55
+ }
56
+
57
+ /**
58
+ * Release a slot after a dispatch completes. Stores sessionId (if returned
59
+ * by the plugin) for subsequent reuse.
60
+ */
61
+ release(slot, sessionId) {
62
+ slot.inFlight = false
63
+ if (sessionId) slot.sessionId = sessionId
64
+ }
65
+
66
+ /**
67
+ * Drop a slot entirely (e.g. plugin reported hard failure or caller asked
68
+ * to reset the session).
69
+ */
70
+ drop(parentSessionId, slot) {
71
+ const key = this._key(parentSessionId)
72
+ const slots = this._slots.get(key)
73
+ if (!slots) return
74
+ const idx = slots.indexOf(slot)
75
+ if (idx >= 0) slots.splice(idx, 1)
76
+ }
77
+
78
+ /** Dispose all slots for a parent session (call on parent termination). */
79
+ disposeParent(parentSessionId) {
80
+ this._slots.delete(this._key(parentSessionId))
81
+ }
82
+
83
+ /** Inspection helper (for diagnostics). */
84
+ snapshot() {
85
+ const out = {}
86
+ for (const [parent, slots] of this._slots.entries()) {
87
+ out[parent] = slots.map(s => ({
88
+ pluginName: s.pluginName,
89
+ sessionId: s.sessionId,
90
+ inFlight: s.inFlight,
91
+ }))
92
+ }
93
+ return out
94
+ }
95
+ }
96
+
97
+ module.exports = { SessionPool }