@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
|
@@ -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
|
|
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
|
*/
|
package/core/lib/llm-checker.js
CHANGED
|
@@ -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, '.
|
|
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
|
-
|
|
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
|
|
50
|
+
const { getActiveSkillDirs } = require('../llm-plugins/lib/skill-dirs')
|
|
51
|
+
const roots = getActiveSkillDirs()
|
|
49
52
|
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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,
|
|
81
|
+
await fs.writeFile(skillMdPath, original, 'utf-8')
|
|
79
82
|
} catch (err) {
|
|
80
|
-
console.error(`[TemplateExpander] Failed to restore ${
|
|
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 }
|