@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.
package/linux/bin/hq CHANGED
@@ -10,10 +10,15 @@
10
10
  # API_TOKEN - Minion API token for authentication
11
11
  #
12
12
  # Usage:
13
- # hq fetch skill <name> - Get skill details (content, description, files)
14
- # hq fetch workflow <name> - Get workflow details (pipeline, cron, etc.)
15
- # hq fetch project <id> - Get project info (name, description, role)
16
- # hq fetch project-context <id> - Get project context (shared Markdown document)
13
+ # hq fetch skill <name> - Get skill details (content, description, files)
14
+ # hq fetch workflow <name> - Get workflow details (pipeline, cron, etc.)
15
+ # hq fetch project <id> - Get project info (name, description, role)
16
+ # hq fetch project-context <id> - Get project context (shared Markdown document)
17
+ # hq fetch dag-workflow <id> - Get DAG workflow details (graph, version)
18
+ # hq fetch dag-execution <id> - Get DAG execution details (nodes, status)
19
+ # hq create dag-workflow <body.json> - Create a DAG workflow (PM only). Body: {project_id, name, graph?, ...}
20
+ # hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only). Body: {graph?, content?, ...}
21
+ # hq publish dag-workflow <id> - Publish the draft as a new version (PM only, validated)
17
22
 
18
23
  set -euo pipefail
19
24
 
@@ -39,12 +44,36 @@ format_json() {
39
44
  fi
40
45
  }
41
46
 
47
+ # Validate that a file contains syntactically valid JSON. Exits with error on failure.
48
+ validate_json_file() {
49
+ local file="$1"
50
+ if [ ! -f "$file" ]; then
51
+ echo "Error: file not found: $file" >&2
52
+ exit 1
53
+ fi
54
+ if command -v jq &>/dev/null; then
55
+ if ! jq empty "$file" 2>/dev/null; then
56
+ echo "Error: invalid JSON syntax in $file" >&2
57
+ jq empty "$file" || true
58
+ exit 1
59
+ fi
60
+ elif command -v python3 &>/dev/null; then
61
+ if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$file" 2>/dev/null; then
62
+ echo "Error: invalid JSON syntax in $file" >&2
63
+ python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$file" || true
64
+ exit 1
65
+ fi
66
+ else
67
+ echo "Error: neither jq nor python3 is available to validate JSON" >&2
68
+ exit 1
69
+ fi
70
+ }
71
+
42
72
  fetch_resource() {
43
73
  local url="$1"
44
74
  local response
45
75
  local http_code
46
76
 
47
- # Fetch with HTTP status code
48
77
  response=$(curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_TOKEN" "$url")
49
78
  http_code=$(echo "$response" | tail -1)
50
79
  body=$(echo "$response" | sed '$d')
@@ -58,6 +87,68 @@ fetch_resource() {
58
87
  fi
59
88
  }
60
89
 
90
+ # Send a JSON request body from a file. Method is POST or PUT.
91
+ send_json_request() {
92
+ local method="$1"
93
+ local url="$2"
94
+ local file="$3"
95
+ local response
96
+ local http_code
97
+
98
+ response=$(curl -s -w "\n%{http_code}" -X "$method" \
99
+ -H "Authorization: Bearer $API_TOKEN" \
100
+ -H "Content-Type: application/json" \
101
+ --data-binary "@$file" \
102
+ "$url")
103
+ http_code=$(echo "$response" | tail -1)
104
+ body=$(echo "$response" | sed '$d')
105
+
106
+ if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
107
+ echo "$body" | format_json
108
+ else
109
+ echo "Error: HQ API returned HTTP $http_code" >&2
110
+ echo "$body" >&2
111
+ exit 1
112
+ fi
113
+ }
114
+
115
+ # Send a body-less POST (e.g., publish).
116
+ send_empty_post() {
117
+ local url="$1"
118
+ local response
119
+ local http_code
120
+
121
+ response=$(curl -s -w "\n%{http_code}" -X POST \
122
+ -H "Authorization: Bearer $API_TOKEN" \
123
+ -H "Content-Length: 0" \
124
+ "$url")
125
+ http_code=$(echo "$response" | tail -1)
126
+ body=$(echo "$response" | sed '$d')
127
+
128
+ if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
129
+ echo "$body" | format_json
130
+ else
131
+ echo "Error: HQ API returned HTTP $http_code" >&2
132
+ echo "$body" >&2
133
+ exit 1
134
+ fi
135
+ }
136
+
137
+ print_usage() {
138
+ echo "HQ API helper for minion chat" >&2
139
+ echo "" >&2
140
+ echo "Usage:" >&2
141
+ echo " hq fetch skill <name> - Get skill details" >&2
142
+ echo " hq fetch workflow <name> - Get workflow details" >&2
143
+ echo " hq fetch project <id> - Get project info" >&2
144
+ echo " hq fetch project-context <id> - Get project context" >&2
145
+ echo " hq fetch dag-workflow <id> - Get DAG workflow details" >&2
146
+ echo " hq fetch dag-execution <id> - Get DAG execution details" >&2
147
+ echo " hq create dag-workflow <body.json> - Create a DAG workflow (PM only)" >&2
148
+ echo " hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only)" >&2
149
+ echo " hq publish dag-workflow <id> - Publish the draft as a new version (PM only)" >&2
150
+ }
151
+
61
152
  # Main command dispatch
62
153
  case "${1:-}" in
63
154
  fetch)
@@ -65,7 +156,7 @@ case "${1:-}" in
65
156
  identifier="${3:-}"
66
157
 
67
158
  if [ -z "$resource" ] || [ -z "$identifier" ]; then
68
- echo "Usage: hq fetch {skill|workflow|project|project-context} <identifier>" >&2
159
+ echo "Usage: hq fetch {skill|workflow|project|project-context|dag-workflow|dag-execution} <identifier>" >&2
69
160
  exit 1
70
161
  fi
71
162
 
@@ -77,7 +168,6 @@ case "${1:-}" in
77
168
  fetch_resource "$BASE_URL/workflows/$identifier"
78
169
  ;;
79
170
  project)
80
- # Fetch all projects and filter by ID
81
171
  response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$BASE_URL/me/projects")
82
172
  if command -v jq &>/dev/null; then
83
173
  echo "$response" | jq --arg id "$identifier" '.projects[] | select(.id == $id)'
@@ -88,21 +178,84 @@ case "${1:-}" in
88
178
  project-context)
89
179
  fetch_resource "$BASE_URL/me/project/$identifier/context"
90
180
  ;;
181
+ dag-workflow)
182
+ fetch_resource "$BASE_URL/dag-workflows/$identifier"
183
+ ;;
184
+ dag-execution)
185
+ fetch_resource "$BASE_URL/dag-executions/$identifier"
186
+ ;;
91
187
  *)
92
188
  echo "Unknown resource: $resource" >&2
93
- echo "Usage: hq fetch {skill|workflow|project|project-context} <identifier>" >&2
189
+ echo "Usage: hq fetch {skill|workflow|project|project-context|dag-workflow|dag-execution} <identifier>" >&2
190
+ exit 1
191
+ ;;
192
+ esac
193
+ ;;
194
+
195
+ create)
196
+ resource="${2:-}"
197
+ case "$resource" in
198
+ dag-workflow)
199
+ body_file="${3:-}"
200
+ if [ -z "$body_file" ]; then
201
+ echo "Usage: hq create dag-workflow <body.json>" >&2
202
+ echo " body.json must contain at least { project_id, name } and optionally { graph, content, change_summary }" >&2
203
+ exit 1
204
+ fi
205
+ validate_json_file "$body_file"
206
+ send_json_request POST "$BASE_URL/dag-workflows" "$body_file"
207
+ ;;
208
+ *)
209
+ echo "Unknown create resource: $resource" >&2
210
+ echo "Usage: hq create dag-workflow <body.json>" >&2
94
211
  exit 1
95
212
  ;;
96
213
  esac
97
214
  ;;
215
+
216
+ put)
217
+ resource="${2:-}"
218
+ case "$resource" in
219
+ dag-workflow)
220
+ id="${3:-}"
221
+ body_file="${4:-}"
222
+ if [ -z "$id" ] || [ -z "$body_file" ]; then
223
+ echo "Usage: hq put dag-workflow <id> <body.json>" >&2
224
+ echo " body.json may contain { graph, content, change_summary, name, is_active, maturity }" >&2
225
+ exit 1
226
+ fi
227
+ validate_json_file "$body_file"
228
+ send_json_request PUT "$BASE_URL/dag-workflows/$id" "$body_file"
229
+ ;;
230
+ *)
231
+ echo "Unknown put resource: $resource" >&2
232
+ echo "Usage: hq put dag-workflow <id> <body.json>" >&2
233
+ exit 1
234
+ ;;
235
+ esac
236
+ ;;
237
+
238
+ publish)
239
+ resource="${2:-}"
240
+ case "$resource" in
241
+ dag-workflow)
242
+ id="${3:-}"
243
+ if [ -z "$id" ]; then
244
+ echo "Usage: hq publish dag-workflow <id>" >&2
245
+ exit 1
246
+ fi
247
+ send_empty_post "$BASE_URL/dag-workflows/$id/publish"
248
+ ;;
249
+ *)
250
+ echo "Unknown publish resource: $resource" >&2
251
+ echo "Usage: hq publish dag-workflow <id>" >&2
252
+ exit 1
253
+ ;;
254
+ esac
255
+ ;;
256
+
98
257
  *)
99
- echo "HQ API helper for minion chat" >&2
100
- echo "" >&2
101
- echo "Usage:" >&2
102
- echo " hq fetch skill <name> - Get skill details" >&2
103
- echo " hq fetch workflow <name> - Get workflow details" >&2
104
- echo " hq fetch project <id> - Get project info" >&2
105
- echo " hq fetch project-context <id> - Get project context" >&2
258
+ print_usage
106
259
  exit 1
107
260
  ;;
108
261
  esac
@@ -497,10 +497,20 @@ NVNCEOF
497
497
  supervisord)
498
498
  # Build environment line from .env values
499
499
  # Include HOME and DISPLAY since supervisord does not set them when switching user
500
+ #
501
+ # NOTE: Runtime-mutable keys (LLM_COMMAND, REFLECTION_TIME) are intentionally
502
+ # excluded. supervisord's environment= parser has known quirks with nested
503
+ # quotes (e.g. LLM_COMMAND="claude -p '{prompt}'" gets the trailing quote
504
+ # stripped), and values baked into the supervisord conf become stale whenever
505
+ # the config API updates .env at runtime. These keys are loaded exclusively
506
+ # by core/config.js loadEnvFile() from the .env file at process startup.
500
507
  local ENV_LINE="environment="
501
508
  local ENV_PAIRS=("HOME=\"${TARGET_HOME}\"" "DISPLAY=\":99\"")
502
509
  while IFS='=' read -r key value; do
503
510
  [[ -z "$key" || "$key" == \#* ]] && continue
511
+ case "$key" in
512
+ LLM_COMMAND|REFLECTION_TIME) continue ;;
513
+ esac
504
514
  ENV_PAIRS+=("${key}=\"${value}\"")
505
515
  done < /opt/minion-agent/.env
506
516
 
@@ -1028,8 +1038,12 @@ CFEOF
1028
1038
  SVC_HOME=$(getent passwd "$SVC_USER" | cut -d: -f6 || echo "$HOME")
1029
1039
  fi
1030
1040
  local ENV_PAIRS=("HOME=\"${SVC_HOME}\"" "DISPLAY=\":99\"")
1041
+ # Exclude runtime-mutable keys from supervisord env (see setup path comment)
1031
1042
  while IFS='=' read -r key value; do
1032
1043
  [[ -z "$key" || "$key" == \#* ]] && continue
1044
+ case "$key" in
1045
+ LLM_COMMAND|REFLECTION_TIME) continue ;;
1046
+ esac
1033
1047
  ENV_PAIRS+=("${key}=\"${value}\"")
1034
1048
  done < /opt/minion-agent/.env
1035
1049
  ENV_LINE+="$(IFS=,; echo "${ENV_PAIRS[*]}")"
@@ -24,6 +24,7 @@ const { config } = require('../../core/config')
24
24
  const chatStore = require('../../core/stores/chat-store')
25
25
  const { runEndOfDay } = require('../../core/lib/end-of-day')
26
26
  const { DATA_DIR } = require('../../core/lib/platform')
27
+ const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
27
28
 
28
29
  /** @type {import('child_process').ChildProcess | null} */
29
30
  let activeChatChild = null
@@ -336,6 +337,45 @@ async function buildContextPrefix(message, context, sessionId) {
336
337
  )
337
338
  }
338
339
  break
340
+ case 'dag-workflow':
341
+ if (context.projectId && context.dagWorkflowId) {
342
+ parts.push(
343
+ `ユーザーはHQダッシュボードで DAG ワークフロー (ID: ${context.dagWorkflowId}) のエディタ/詳細を閲覧しています。`,
344
+ `DAG ワークフローはノード/エッジ形式でスキル間の依存関係を表現し、fan-out / join / conditional / transform / review をサポートします。`,
345
+ `DAG ワークフロー情報を取得するには以下を実行してください:`,
346
+ ` hq fetch dag-workflow ${context.dagWorkflowId}`,
347
+ `プロジェクトコンテキスト:`,
348
+ ` hq fetch project-context ${context.projectId}`,
349
+ `PMロールの場合、graph JSON を直接編集できます:`,
350
+ ` hq put dag-workflow ${context.dagWorkflowId} <body.json> # ドラフト更新 (構造チェックのみ)`,
351
+ ` hq publish dag-workflow ${context.dagWorkflowId} # ドラフトを新バージョンとして公開 (フル検証)`,
352
+ `新規作成は: hq create dag-workflow <body.json>`,
353
+ `DAG の構造(nodes/edges/node types/scope_path 等)や実行フローの詳細は ~/.minion/docs/api-reference.md の「DAG Workflows」セクション、および ~/.minion/docs/task-guides.md の「DAG ワークフロー」セクションを参照してください。`,
354
+ `取得した内容をもとに回答してください。`
355
+ )
356
+ }
357
+ break
358
+ case 'dag-execution':
359
+ if (context.projectId && context.dagExecutionId) {
360
+ parts.push(
361
+ `ユーザーはHQダッシュボードで DAG 実行 (ID: ${context.dagExecutionId}) の詳細を閲覧しています。`,
362
+ `DAG 実行の詳細(graph_snapshot + 各 node_executions の状態)を取得するには以下を実行してください:`,
363
+ ` hq fetch dag-execution ${context.dagExecutionId}`
364
+ )
365
+ if (context.dagWorkflowId) {
366
+ parts.push(
367
+ `対応するDAGワークフロー定義:`,
368
+ ` hq fetch dag-workflow ${context.dagWorkflowId}`
369
+ )
370
+ }
371
+ parts.push(
372
+ `プロジェクトコンテキスト:`,
373
+ ` hq fetch project-context ${context.projectId}`,
374
+ `ノード状態の意味(pending/waiting/running/completed/failed/skipped, review_status, scope_path 等)は ~/.minion/docs/api-reference.md の「DAG Workflows」セクションを参照してください。`,
375
+ `取得した内容をもとに回答してください。`
376
+ )
377
+ }
378
+ break
339
379
  }
340
380
  }
341
381
 
@@ -362,7 +402,76 @@ function getLlmBinary() {
362
402
  * Tracks block types to correctly forward tool_use vs text events
363
403
  * and counts turns for session management.
364
404
  */
365
- function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
405
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
406
+ // Plugin system path: Primary is set → delegate to plugin
407
+ const primary = getActivePrimary()
408
+ if (primary) {
409
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
410
+ }
411
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
412
+ }
413
+
414
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
415
+ const input = { prompt }
416
+ const activeRef = { current: null }
417
+ activeChatChild = { kill: () => activeRef.current?.kill?.('SIGTERM') }
418
+
419
+ let fullResponse = ''
420
+ let resolvedSessionId = sessionId || null
421
+ let turnCount = 0
422
+
423
+ const emit = event => {
424
+ if (event.type === 'session') {
425
+ resolvedSessionId = event.sessionId
426
+ } else if (event.type === 'delta') {
427
+ fullResponse += event.content
428
+ res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
429
+ } else if (event.type === 'text') {
430
+ fullResponse += event.content
431
+ res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
432
+ turnCount++
433
+ } else {
434
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
435
+ }
436
+ }
437
+
438
+ res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
439
+
440
+ try {
441
+ let output
442
+ if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
443
+ output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
444
+ } else {
445
+ output = await plugin.invoke(input)
446
+ if (output.text) {
447
+ fullResponse = output.text
448
+ res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
449
+ turnCount = 1
450
+ }
451
+ if (output.error) {
452
+ res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
453
+ }
454
+ }
455
+ resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
456
+ } finally {
457
+ activeChatChild = null
458
+ }
459
+
460
+ if (resolvedSessionId) {
461
+ if (!sessionId) {
462
+ await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
463
+ }
464
+ if (fullResponse) {
465
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
466
+ }
467
+ }
468
+
469
+ const session = await chatStore.load(workspaceId)
470
+ const totalTurnCount = session?.turn_count || turnCount
471
+ res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
472
+ }
473
+
474
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
366
475
  return new Promise((resolve, reject) => {
367
476
  const binaryName = getLlmBinary()
368
477
  if (!binaryName) {
@@ -587,7 +696,17 @@ function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage)
587
696
  * @param {string} prompt
588
697
  * @returns {Promise<string>} The result text
589
698
  */
590
- function runQuickLlmCall(prompt) {
699
+ async function runQuickLlmCall(prompt) {
700
+ const primary = getActivePrimary()
701
+ if (primary) {
702
+ const output = await primary.invoke({ prompt, model: primary.name === 'claude' ? 'haiku' : undefined, timeoutMs: 30000 })
703
+ if (output.error) throw new Error(output.error.message)
704
+ return output.text || ''
705
+ }
706
+ return runQuickLlmCallLegacy(prompt)
707
+ }
708
+
709
+ function runQuickLlmCallLegacy(prompt) {
591
710
  return new Promise((resolve, reject) => {
592
711
  const binaryName = getLlmBinary()
593
712
  if (!binaryName) {
@@ -23,6 +23,9 @@ const routineStore = require('../core/stores/routine-store')
23
23
  const logManager = require('../core/lib/log-manager')
24
24
  const runningTasks = require('../core/lib/running-tasks')
25
25
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
26
+ const { getActivePrimary } = require('../core/llm-plugins/lib/active')
27
+ const os = require('os')
28
+ const path = require('path')
26
29
 
27
30
  // Active cron jobs keyed by routine ID
28
31
  const activeJobs = new Map()
@@ -64,14 +67,19 @@ async function executeRoutineSession(routine, executionId, skillNames) {
64
67
  const sessionName = generateSessionName(routine.id, executionId)
65
68
 
66
69
  // Build prompt: run each skill in sequence, then execution-report
67
- const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
70
+ const primary = getActivePrimary()
71
+ const formatSkill = primary && typeof primary.formatSkillInvocation === 'function'
72
+ ? primary.formatSkillInvocation.bind(primary)
73
+ : (name) => `/${name}`
74
+ const skillCommands = skillNames.map(name => formatSkill(name)).join(', then ')
68
75
 
69
76
  // Inject routine context as prompt prefix (mirrors workflow-runner's role injection pattern)
70
77
  const contextPrefix = routine.context
71
78
  ? `## Context\n\n${routine.context}\n\n---\n\n`
72
79
  : ''
73
80
 
74
- const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
81
+ const reportSkill = formatSkill('execution-report')
82
+ const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run ${reportSkill} to report the results.`
75
83
 
76
84
  // Exit code file to capture CLI result
77
85
  const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
@@ -106,12 +114,19 @@ async function executeRoutineSession(routine, executionId, skillNames) {
106
114
  // Remove old exit code file
107
115
  await execAsync(`rm -f "${exitCodeFile}"`)
108
116
 
109
- // Build the command to run in tmux
110
- if (!config.LLM_COMMAND) {
111
- throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env (e.g., LLM_COMMAND="claude -p \'{prompt}\'")')
117
+ // Build the LLM invocation (prefer plugin system, fall back to LLM_COMMAND).
118
+ let llmCommand
119
+ const primary = getActivePrimary()
120
+ if (primary && typeof primary.buildShellInvocation === 'function') {
121
+ const promptFile = path.join(os.tmpdir(), `minion-routine-prompt-${sessionName}.txt`)
122
+ await fs.writeFile(promptFile, prompt, 'utf-8')
123
+ llmCommand = primary.buildShellInvocation({ promptFile })
124
+ } else if (config.LLM_COMMAND) {
125
+ const escapedPrompt = prompt.replace(/'/g, "'\\''")
126
+ llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
127
+ } else {
128
+ throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
112
129
  }
113
- const escapedPrompt = prompt.replace(/'/g, "'\\''")
114
- const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
115
130
 
116
131
  // Create tmux session with the LLM command.
117
132
  // PATH, HOME, DISPLAY, and minion secrets are already set in
package/linux/server.js CHANGED
@@ -80,6 +80,7 @@ const { threadRoutes } = require('../core/routes/threads')
80
80
  const { todoRoutes } = require('../core/routes/todos')
81
81
  const { emailRoutes } = require('../core/routes/emails')
82
82
  const { daemonRoutes } = require('../core/routes/daemons')
83
+ const { llmRoutes } = require('../core/routes/llm')
83
84
 
84
85
  // Linux-specific routes
85
86
  const { commandRoutes, getProcessManager, getAllowedCommands } = require('./routes/commands')
@@ -290,6 +291,7 @@ async function registerAllRoutes(app) {
290
291
  await app.register(todoRoutes)
291
292
  await app.register(emailRoutes)
292
293
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
294
+ await app.register(llmRoutes)
293
295
 
294
296
  // Linux-specific routes
295
297
  await app.register(commandRoutes)
@@ -22,6 +22,9 @@ const workflowStore = require('../core/stores/workflow-store')
22
22
  const logManager = require('../core/lib/log-manager')
23
23
  const runningTasks = require('../core/lib/running-tasks')
24
24
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
25
+ const { getActivePrimary } = require('../core/llm-plugins/lib/active')
26
+ const os = require('os')
27
+ const path = require('path')
25
28
 
26
29
  // Active cron jobs keyed by workflow ID
27
30
  const activeJobs = new Map()
@@ -61,12 +64,17 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
61
64
  const homeDir = config.HOME_DIR
62
65
  const sessionName = generateSessionName(workflow.id, executionId)
63
66
 
64
- // Build prompt: run each skill in sequence
65
- const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
67
+ // Build prompt: run each skill in sequence.
68
+ // Use the Primary plugin's invocation syntax (Claude/Codex use /skill, Gemini uses natural language).
69
+ const primary = getActivePrimary()
70
+ const formatSkill = primary && typeof primary.formatSkillInvocation === 'function'
71
+ ? primary.formatSkillInvocation.bind(primary)
72
+ : (name) => `/${name}`
73
+ const skillCommands = skillNames.map(name => formatSkill(name)).join(', then ')
66
74
 
67
75
  // Inject role context if provided (e.g. "pm" or "engineer")
68
76
  const rolePrefix = options.role
69
- ? `You are acting as the "${options.role}" role in this session. Read ~/.minion/roles/${options.role}.md for your role guidelines before proceeding.\n\n`
77
+ ? `You are acting as the ${options.role} role in this session. Read ~/.minion/roles/${options.role}.md for your role guidelines before proceeding.\n\n`
70
78
  : ''
71
79
 
72
80
  // Inject revision feedback if this is a re-execution after reviewer requested changes
@@ -110,12 +118,22 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
110
118
  // Remove old exit code file
111
119
  await execAsync(`rm -f "${exitCodeFile}"`)
112
120
 
113
- // Build the command to run in tmux
114
- if (!config.LLM_COMMAND) {
115
- throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env (e.g., LLM_COMMAND="claude -p \'{prompt}\'")')
121
+ // Build the LLM invocation. Prefer the plugin system; fall back to LLM_COMMAND.
122
+ let llmCommand
123
+ const primary = getActivePrimary()
124
+ if (primary && typeof primary.buildShellInvocation === 'function') {
125
+ // Write prompt to a temp file and have the plugin build `<bin> ... < file`.
126
+ // This bypasses shell quoting entirely (root cause of the historical
127
+ // LLM_COMMAND quote-corruption class of bugs).
128
+ const promptFile = path.join(os.tmpdir(), `minion-workflow-prompt-${sessionName}.txt`)
129
+ await fs.writeFile(promptFile, prompt, 'utf-8')
130
+ llmCommand = primary.buildShellInvocation({ promptFile })
131
+ } else if (config.LLM_COMMAND) {
132
+ const escapedPrompt = prompt.replace(/'/g, "'\\''")
133
+ llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
134
+ } else {
135
+ throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
116
136
  }
117
- const escapedPrompt = prompt.replace(/'/g, "'\\''")
118
- const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
119
137
 
120
138
  // Create tmux session with the LLM command.
121
139
  // PATH, HOME, DISPLAY, and minion secrets are already set in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.17.0",
3
+ "version": "3.22.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -14,18 +14,22 @@ You are an AI agent running on a Minion VPS, managed by @geekbeer/minion.
14
14
  ```
15
15
  Project (組織・課金単位)
16
16
  ├── Context (markdown, PMが更新)
17
- ├── Members (minion + role: pm | engineer)
18
- └── Workflows (スキルのパイプライン + オプションcronスケジュール)
19
- ├── Versions (pipeline の不変スナップショット)
20
- └── Executions (実行履歴, ステップごとの進捗)
17
+ ├── Members (minion + role: pm | engineer | accountant)
18
+ ├── Workflows (スキルの線形パイプライン + オプションcronスケジュール)
19
+ ├── Versions (pipeline の不変スナップショット)
20
+ └── Executions (実行履歴, ステップごとの進捗)
21
+ └── DAG Workflows (ノード/エッジ形式のワークフロー, beta)
22
+ ├── Versions (graph の不変スナップショット)
23
+ └── Executions (node_executions の集合, scope-aware)
21
24
 
22
25
  Minion
23
26
  └── Routines (ミニオンローカルの定期タスク, cron付き)
24
27
  ```
25
28
 
26
- - **Workflow**: プロジェクトスコープ。バージョン管理されたスキルパイプライン。
29
+ - **Workflow**: プロジェクトスコープ。線形パイプライン形式のバージョン管理ワークフロー。ミニオンAPIで push/fetch 可能。
30
+ - **DAG Workflow**: プロジェクトスコープ。ノード/エッジで依存関係を表現する新方式。fan-out / join / conditional / transform / review をサポート。作成・編集はHQダッシュボードのみ、ミニオンは `dag-step-poller` で自動実行。詳細は `~/.minion/docs/api-reference.md` の「DAG Workflows」と `~/.minion/docs/task-guides.md` の「DAG ワークフロー」を参照。
27
31
  - **Routine**: ミニオンスコープ。ミニオンローカルの定期タスク。
28
- - ミニオンは複数プロジェクトに `pm` または `engineer` として参加できる。
32
+ - ミニオンは複数プロジェクトに `pm`、`engineer`、`accountant` として参加できる。
29
33
 
30
34
  ## Email
31
35
 
@@ -153,7 +157,9 @@ Note: Codex CLI の `.codex/` ディレクトリはLLMからの直接編集が
153
157
 
154
158
  `$HQ_URL/api/minion/*` — 認証: `Authorization: Bearer $API_TOKEN`
155
159
 
156
- 主なカテゴリ: Projects, Context, Workflows, Skills, Executions, Routines, Reports
160
+ 主なカテゴリ: Projects, Context, Workflows, DAG Workflows, Skills, Executions, Routines, Reports
161
+
162
+ DAG ワークフローのランタイム API は `$HQ_URL/api/dag/minion/*`(pending-nodes / claim-node / node-complete)。`dag-step-poller` デーモンが自動でポーリングするため、通常ミニオンのAI側から直接叩くことは無い。
157
163
 
158
164
  ## Environment Variables
159
165