@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/win/bin/hq.ps1 CHANGED
@@ -4,9 +4,9 @@
4
4
  HQ API helper for minion chat context (Windows version)
5
5
 
6
6
  .DESCRIPTION
7
- Fetches resource details from the HQ server API.
8
- Used by Claude CLI during chat to retrieve information about
9
- skills, workflows, and projects that the user is viewing on the dashboard.
7
+ Fetches and manipulates resources on the HQ server API.
8
+ Used by Claude CLI during chat to retrieve / edit information about
9
+ skills, workflows, projects, and DAG workflows.
10
10
 
11
11
  Environment variables (inherited from minion server):
12
12
  HQ_URL - HQ server URL (e.g., https://minion-agent.com)
@@ -17,6 +17,11 @@
17
17
  .\hq.ps1 fetch workflow <name>
18
18
  .\hq.ps1 fetch project <id>
19
19
  .\hq.ps1 fetch project-context <id>
20
+ .\hq.ps1 fetch dag-workflow <id>
21
+ .\hq.ps1 fetch dag-execution <id>
22
+ .\hq.ps1 create dag-workflow <body.json>
23
+ .\hq.ps1 put dag-workflow <id> <body.json>
24
+ .\hq.ps1 publish dag-workflow <id>
20
25
  #>
21
26
 
22
27
  param(
@@ -24,15 +29,17 @@ param(
24
29
  [string]$Command,
25
30
 
26
31
  [Parameter(Position = 1)]
27
- [string]$Resource,
32
+ [string]$Arg1,
28
33
 
29
34
  [Parameter(Position = 2)]
30
- [string]$Identifier
35
+ [string]$Arg2,
36
+
37
+ [Parameter(Position = 3)]
38
+ [string]$Arg3
31
39
  )
32
40
 
33
41
  $ErrorActionPreference = 'Stop'
34
42
 
35
- # Validate required environment variables
36
43
  if (-not $env:HQ_URL) {
37
44
  Write-Error "Error: HQ_URL is not set"
38
45
  exit 1
@@ -47,10 +54,46 @@ $Headers = @{ 'Authorization' = "Bearer $($env:API_TOKEN)" }
47
54
 
48
55
  function Invoke-HqApi {
49
56
  param([string]$Url)
50
-
51
57
  try {
52
58
  $response = Invoke-RestMethod -Uri $Url -Headers $Headers -Method Get -ErrorAction Stop
53
- $response | ConvertTo-Json -Depth 10
59
+ $response | ConvertTo-Json -Depth 20
60
+ }
61
+ catch {
62
+ $statusCode = $_.Exception.Response.StatusCode.value__
63
+ Write-Error "Error: HQ API returned HTTP $statusCode"
64
+ Write-Error $_.ErrorDetails.Message
65
+ exit 1
66
+ }
67
+ }
68
+
69
+ function Assert-ValidJsonFile {
70
+ param([string]$Path)
71
+ if (-not (Test-Path -Path $Path -PathType Leaf)) {
72
+ Write-Error "Error: file not found: $Path"
73
+ exit 1
74
+ }
75
+ try {
76
+ $raw = Get-Content -Raw -Path $Path
77
+ $null = $raw | ConvertFrom-Json -ErrorAction Stop
78
+ }
79
+ catch {
80
+ Write-Error "Error: invalid JSON syntax in $Path"
81
+ Write-Error $_.Exception.Message
82
+ exit 1
83
+ }
84
+ }
85
+
86
+ function Send-JsonRequest {
87
+ param(
88
+ [string]$Method,
89
+ [string]$Url,
90
+ [string]$Path
91
+ )
92
+ $raw = Get-Content -Raw -Path $Path
93
+ try {
94
+ $response = Invoke-RestMethod -Uri $Url -Headers $Headers -Method $Method `
95
+ -ContentType 'application/json' -Body $raw -ErrorAction Stop
96
+ $response | ConvertTo-Json -Depth 20
54
97
  }
55
98
  catch {
56
99
  $statusCode = $_.Exception.Response.StatusCode.value__
@@ -60,49 +103,134 @@ function Invoke-HqApi {
60
103
  }
61
104
  }
62
105
 
106
+ function Send-EmptyPost {
107
+ param([string]$Url)
108
+ try {
109
+ $response = Invoke-RestMethod -Uri $Url -Headers $Headers -Method Post `
110
+ -ContentType 'application/json' -Body '' -ErrorAction Stop
111
+ $response | ConvertTo-Json -Depth 20
112
+ }
113
+ catch {
114
+ $statusCode = $_.Exception.Response.StatusCode.value__
115
+ Write-Error "Error: HQ API returned HTTP $statusCode"
116
+ Write-Error $_.ErrorDetails.Message
117
+ exit 1
118
+ }
119
+ }
120
+
121
+ function Show-Usage {
122
+ Write-Host "HQ API helper for minion chat" -ForegroundColor Cyan
123
+ Write-Host ""
124
+ Write-Host "Usage:"
125
+ Write-Host " hq fetch skill <name> - Get skill details"
126
+ Write-Host " hq fetch workflow <name> - Get workflow details"
127
+ Write-Host " hq fetch project <id> - Get project info"
128
+ Write-Host " hq fetch project-context <id> - Get project context"
129
+ Write-Host " hq fetch dag-workflow <id> - Get DAG workflow details"
130
+ Write-Host " hq fetch dag-execution <id> - Get DAG execution details"
131
+ Write-Host " hq create dag-workflow <body.json> - Create a DAG workflow (PM only)"
132
+ Write-Host " hq put dag-workflow <id> <body.json> - Update DAG workflow draft (PM only)"
133
+ Write-Host " hq publish dag-workflow <id> - Publish the draft as a new version (PM only)"
134
+ }
135
+
63
136
  switch ($Command) {
64
137
  'fetch' {
138
+ $Resource = $Arg1
139
+ $Identifier = $Arg2
65
140
  if (-not $Resource -or -not $Identifier) {
66
- Write-Error "Usage: hq fetch {skill|workflow|project|project-context} <identifier>"
141
+ Write-Error "Usage: hq fetch {skill|workflow|project|project-context|dag-workflow|dag-execution} <identifier>"
67
142
  exit 1
68
143
  }
69
144
 
70
145
  switch ($Resource) {
71
- 'skill' {
72
- Invoke-HqApi "$BaseUrl/skills/$Identifier"
73
- }
74
- 'workflow' {
75
- Invoke-HqApi "$BaseUrl/workflows/$Identifier"
76
- }
146
+ 'skill' { Invoke-HqApi "$BaseUrl/skills/$Identifier" }
147
+ 'workflow' { Invoke-HqApi "$BaseUrl/workflows/$Identifier" }
77
148
  'project' {
78
149
  $response = Invoke-RestMethod -Uri "$BaseUrl/me/projects" -Headers $Headers -Method Get
79
150
  $project = $response.projects | Where-Object { $_.id -eq $Identifier }
80
151
  if ($project) {
81
- $project | ConvertTo-Json -Depth 10
152
+ $project | ConvertTo-Json -Depth 20
82
153
  }
83
154
  else {
84
155
  Write-Error "Project not found: $Identifier"
85
156
  exit 1
86
157
  }
87
158
  }
88
- 'project-context' {
89
- Invoke-HqApi "$BaseUrl/me/project/$Identifier/context"
90
- }
159
+ 'project-context' { Invoke-HqApi "$BaseUrl/me/project/$Identifier/context" }
160
+ 'dag-workflow' { Invoke-HqApi "$BaseUrl/dag-workflows/$Identifier" }
161
+ 'dag-execution' { Invoke-HqApi "$BaseUrl/dag-executions/$Identifier" }
91
162
  default {
92
163
  Write-Error "Unknown resource: $Resource"
93
- Write-Error "Usage: hq fetch {skill|workflow|project|project-context} <identifier>"
164
+ Write-Error "Usage: hq fetch {skill|workflow|project|project-context|dag-workflow|dag-execution} <identifier>"
165
+ exit 1
166
+ }
167
+ }
168
+ }
169
+
170
+ 'create' {
171
+ $Resource = $Arg1
172
+ switch ($Resource) {
173
+ 'dag-workflow' {
174
+ $BodyFile = $Arg2
175
+ if (-not $BodyFile) {
176
+ Write-Error "Usage: hq create dag-workflow <body.json>"
177
+ Write-Error " body.json must contain at least { project_id, name } and optionally { graph, content, change_summary }"
178
+ exit 1
179
+ }
180
+ Assert-ValidJsonFile -Path $BodyFile
181
+ Send-JsonRequest -Method 'Post' -Url "$BaseUrl/dag-workflows" -Path $BodyFile
182
+ }
183
+ default {
184
+ Write-Error "Unknown create resource: $Resource"
185
+ Write-Error "Usage: hq create dag-workflow <body.json>"
186
+ exit 1
187
+ }
188
+ }
189
+ }
190
+
191
+ 'put' {
192
+ $Resource = $Arg1
193
+ switch ($Resource) {
194
+ 'dag-workflow' {
195
+ $Id = $Arg2
196
+ $BodyFile = $Arg3
197
+ if (-not $Id -or -not $BodyFile) {
198
+ Write-Error "Usage: hq put dag-workflow <id> <body.json>"
199
+ Write-Error " body.json may contain { graph, content, change_summary, name, is_active, maturity }"
200
+ exit 1
201
+ }
202
+ Assert-ValidJsonFile -Path $BodyFile
203
+ Send-JsonRequest -Method 'Put' -Url "$BaseUrl/dag-workflows/$Id" -Path $BodyFile
204
+ }
205
+ default {
206
+ Write-Error "Unknown put resource: $Resource"
207
+ Write-Error "Usage: hq put dag-workflow <id> <body.json>"
94
208
  exit 1
95
209
  }
96
210
  }
97
211
  }
212
+
213
+ 'publish' {
214
+ $Resource = $Arg1
215
+ switch ($Resource) {
216
+ 'dag-workflow' {
217
+ $Id = $Arg2
218
+ if (-not $Id) {
219
+ Write-Error "Usage: hq publish dag-workflow <id>"
220
+ exit 1
221
+ }
222
+ Send-EmptyPost -Url "$BaseUrl/dag-workflows/$Id/publish"
223
+ }
224
+ default {
225
+ Write-Error "Unknown publish resource: $Resource"
226
+ Write-Error "Usage: hq publish dag-workflow <id>"
227
+ exit 1
228
+ }
229
+ }
230
+ }
231
+
98
232
  default {
99
- Write-Host "HQ API helper for minion chat" -ForegroundColor Cyan
100
- Write-Host ""
101
- Write-Host "Usage:"
102
- Write-Host " hq fetch skill <name> - Get skill details"
103
- Write-Host " hq fetch workflow <name> - Get workflow details"
104
- Write-Host " hq fetch project <id> - Get project info"
105
- Write-Host " hq fetch project-context <id> - Get project context"
233
+ Show-Usage
106
234
  exit 1
107
235
  }
108
236
  }
@@ -21,6 +21,7 @@ const { config } = require('../../core/config')
21
21
  const chatStore = require('../../core/stores/chat-store')
22
22
  const { DATA_DIR } = require('../../core/lib/platform')
23
23
  const { runEndOfDay } = require('../../core/lib/end-of-day')
24
+ const { getActivePrimary } = require('../../core/llm-plugins/lib/active')
24
25
 
25
26
  let activeChatChild = null
26
27
  let wslModeActive = false
@@ -398,6 +399,45 @@ async function buildContextPrefix(message, context, sessionId) {
398
399
  )
399
400
  }
400
401
  break
402
+ case 'dag-workflow':
403
+ if (context.projectId && context.dagWorkflowId) {
404
+ parts.push(
405
+ `ユーザーはHQダッシュボードで DAG ワークフロー (ID: ${context.dagWorkflowId}) のエディタ/詳細を閲覧しています。`,
406
+ `DAG ワークフローはノード/エッジ形式でスキル間の依存関係を表現し、fan-out / join / conditional / transform / review をサポートします。`,
407
+ `DAG ワークフロー情報を取得するには以下を実行してください:`,
408
+ ` hq fetch dag-workflow ${context.dagWorkflowId}`,
409
+ `プロジェクトコンテキスト:`,
410
+ ` hq fetch project-context ${context.projectId}`,
411
+ `PMロールの場合、graph JSON を直接編集できます:`,
412
+ ` hq put dag-workflow ${context.dagWorkflowId} <body.json> # ドラフト更新 (構造チェックのみ)`,
413
+ ` hq publish dag-workflow ${context.dagWorkflowId} # ドラフトを新バージョンとして公開 (フル検証)`,
414
+ `新規作成は: hq create dag-workflow <body.json>`,
415
+ `DAG の構造(nodes/edges/node types/scope_path 等)や実行フローの詳細は ~/.minion/docs/api-reference.md の「DAG Workflows」セクション、および ~/.minion/docs/task-guides.md の「DAG ワークフロー」セクションを参照してください。`,
416
+ `取得した内容をもとに回答してください。`
417
+ )
418
+ }
419
+ break
420
+ case 'dag-execution':
421
+ if (context.projectId && context.dagExecutionId) {
422
+ parts.push(
423
+ `ユーザーはHQダッシュボードで DAG 実行 (ID: ${context.dagExecutionId}) の詳細を閲覧しています。`,
424
+ `DAG 実行の詳細(graph_snapshot + 各 node_executions の状態)を取得するには以下を実行してください:`,
425
+ ` hq fetch dag-execution ${context.dagExecutionId}`
426
+ )
427
+ if (context.dagWorkflowId) {
428
+ parts.push(
429
+ `対応するDAGワークフロー定義:`,
430
+ ` hq fetch dag-workflow ${context.dagWorkflowId}`
431
+ )
432
+ }
433
+ parts.push(
434
+ `プロジェクトコンテキスト:`,
435
+ ` hq fetch project-context ${context.projectId}`,
436
+ `ノード状態の意味(pending/waiting/running/completed/failed/skipped, review_status, scope_path 等)は ~/.minion/docs/api-reference.md の「DAG Workflows」セクションを参照してください。`,
437
+ `取得した内容をもとに回答してください。`
438
+ )
439
+ }
440
+ break
401
441
  }
402
442
  }
403
443
  if (parts.length > 0) return `${parts.join('\n')}\n\n${message}`
@@ -416,7 +456,70 @@ function getLlmBinary() {
416
456
  * Tracks block types to correctly forward tool_use vs text events
417
457
  * and counts turns for session management.
418
458
  */
419
- function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
459
+ async function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage) {
460
+ const primary = getActivePrimary()
461
+ if (primary) {
462
+ return streamViaPlugin(primary, res, prompt, sessionId, workspaceId, originalMessage)
463
+ }
464
+ return streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage)
465
+ }
466
+
467
+ async function streamViaPlugin(plugin, res, prompt, sessionId, workspaceId, originalMessage) {
468
+ const input = { prompt }
469
+ const activeRef = { current: null }
470
+
471
+ let fullResponse = ''
472
+ let resolvedSessionId = sessionId || null
473
+ let turnCount = 0
474
+
475
+ const emit = event => {
476
+ if (event.type === 'session') {
477
+ resolvedSessionId = event.sessionId
478
+ } else if (event.type === 'delta') {
479
+ fullResponse += event.content
480
+ res.write(`data: ${JSON.stringify({ type: 'delta', content: event.content })}\n\n`)
481
+ } else if (event.type === 'text') {
482
+ fullResponse += event.content
483
+ res.write(`data: ${JSON.stringify({ type: 'text', content: event.content })}\n\n`)
484
+ turnCount++
485
+ } else {
486
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
487
+ }
488
+ }
489
+
490
+ res.on('close', () => { activeRef.current?.kill?.('SIGTERM') })
491
+
492
+ let output
493
+ if (plugin.capabilities.streaming && typeof plugin.stream === 'function') {
494
+ output = await plugin.stream(input, emit, { resumeSessionId: sessionId, activeChildRef: activeRef })
495
+ } else {
496
+ output = await plugin.invoke(input)
497
+ if (output.text) {
498
+ fullResponse = output.text
499
+ res.write(`data: ${JSON.stringify({ type: 'text', content: output.text })}\n\n`)
500
+ turnCount = 1
501
+ }
502
+ if (output.error) {
503
+ res.write(`data: ${JSON.stringify({ type: 'error', error: output.error.message })}\n\n`)
504
+ }
505
+ }
506
+ resolvedSessionId = output?.metadata?.sessionId || resolvedSessionId
507
+
508
+ if (resolvedSessionId) {
509
+ if (!sessionId) {
510
+ await chatStore.addMessage(resolvedSessionId, { role: 'user', content: originalMessage || prompt }, undefined, workspaceId)
511
+ }
512
+ if (fullResponse) {
513
+ await chatStore.addMessage(resolvedSessionId, { role: 'assistant', content: fullResponse }, turnCount, workspaceId)
514
+ }
515
+ }
516
+
517
+ const session = await chatStore.load(workspaceId)
518
+ const totalTurnCount = session?.turn_count || turnCount
519
+ res.write(`data: ${JSON.stringify({ type: 'done', session_id: resolvedSessionId, turn_count: totalTurnCount })}\n\n`)
520
+ }
521
+
522
+ function streamViaLegacyLlmCommand(res, prompt, sessionId, workspaceId, originalMessage) {
420
523
  return new Promise((resolve, reject) => {
421
524
  const binaryName = getLlmBinary()
422
525
  if (!binaryName) {
@@ -597,7 +700,17 @@ function streamLlmResponse(res, prompt, sessionId, workspaceId, originalMessage)
597
700
  /**
598
701
  * Run a quick non-streaming LLM call (for summarization etc.)
599
702
  */
600
- function runQuickLlmCall(prompt) {
703
+ async function runQuickLlmCall(prompt) {
704
+ const primary = getActivePrimary()
705
+ if (primary) {
706
+ const output = await primary.invoke({ prompt, model: primary.name === 'claude' ? 'haiku' : undefined, timeoutMs: 30000 })
707
+ if (output.error) throw new Error(output.error.message)
708
+ return output.text || ''
709
+ }
710
+ return runQuickLlmCallLegacy(prompt)
711
+ }
712
+
713
+ function runQuickLlmCallLegacy(prompt) {
601
714
  return new Promise((resolve, reject) => {
602
715
  const binaryName = getLlmBinary()
603
716
  if (!binaryName) {
@@ -18,6 +18,8 @@ const routineStore = require('../core/stores/routine-store')
18
18
  const logManager = require('../core/lib/log-manager')
19
19
  const { activeSessions } = require('./workflow-runner')
20
20
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
21
+ const { getActivePrimary } = require('../core/llm-plugins/lib/active')
22
+ const os = require('os')
21
23
 
22
24
  const activeJobs = new Map()
23
25
  const runningExecutions = new Map()
@@ -48,11 +50,16 @@ async function executeRoutineSession(routine, executionId, skillNames) {
48
50
  const homeDir = config.HOME_DIR
49
51
  const sessionName = generateSessionName(routine.id, executionId)
50
52
 
51
- const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
53
+ const primary = getActivePrimary()
54
+ const formatSkill = primary && typeof primary.formatSkillInvocation === 'function'
55
+ ? primary.formatSkillInvocation.bind(primary)
56
+ : (name) => `/${name}`
57
+ const skillCommands = skillNames.map(name => formatSkill(name)).join(', then ')
52
58
  const contextPrefix = routine.context
53
59
  ? `## Context\n\n${routine.context}\n\n---\n\n`
54
60
  : ''
55
- const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run /execution-report to report the results.`
61
+ const reportSkill = formatSkill('execution-report')
62
+ const prompt = `${contextPrefix}Run the following skills in order: ${skillCommands}. After completing all skills, run ${reportSkill} to report the results.`
56
63
 
57
64
  const logFile = logManager.getLogPath(executionId)
58
65
 
@@ -82,11 +89,18 @@ async function executeRoutineSession(routine, executionId, skillNames) {
82
89
  activeSessions.delete(sessionName)
83
90
  }
84
91
 
85
- if (!config.LLM_COMMAND) {
86
- throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env')
92
+ let llmCommand
93
+ const primary = getActivePrimary()
94
+ if (primary && typeof primary.buildShellInvocation === 'function') {
95
+ const promptFile = path.join(os.tmpdir(), `minion-routine-prompt-${sessionName}.txt`)
96
+ await fs.writeFile(promptFile, prompt, 'utf-8')
97
+ llmCommand = primary.buildShellInvocation({ promptFile })
98
+ } else if (config.LLM_COMMAND) {
99
+ const escapedPrompt = prompt.replace(/'/g, "''")
100
+ llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
101
+ } else {
102
+ throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
87
103
  }
88
- const escapedPrompt = prompt.replace(/'/g, "''")
89
- const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
90
104
 
91
105
  // PATH, HOME, USERPROFILE, and minion secrets are already set in
92
106
  // process.env at server startup. Per-execution identifiers are added here.
package/win/server.js CHANGED
@@ -62,6 +62,7 @@ const { threadRoutes } = require('../core/routes/threads')
62
62
  const { todoRoutes } = require('../core/routes/todos')
63
63
  const { emailRoutes } = require('../core/routes/emails')
64
64
  const { daemonRoutes } = require('../core/routes/daemons')
65
+ const { llmRoutes } = require('../core/routes/llm')
65
66
 
66
67
  // Validate configuration
67
68
  validate()
@@ -224,6 +225,7 @@ async function registerRoutes(app) {
224
225
  await app.register(todoRoutes)
225
226
  await app.register(emailRoutes)
226
227
  await app.register(daemonRoutes, { heartbeatStatus: () => ({ running: !!heartbeatTimer, last_beat_at: lastBeatAt }) })
228
+ await app.register(llmRoutes)
227
229
 
228
230
  // Shutdown endpoint — allows detached restart/update scripts to trigger
229
231
  // graceful shutdown (offline heartbeat) before force-killing the process.
@@ -24,6 +24,8 @@ const executionStore = require('../core/stores/execution-store')
24
24
  const workflowStore = require('../core/stores/workflow-store')
25
25
  const logManager = require('../core/lib/log-manager')
26
26
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
27
+ const { getActivePrimary } = require('../core/llm-plugins/lib/active')
28
+ const os = require('os')
27
29
 
28
30
  // Active cron jobs keyed by workflow ID
29
31
  const activeJobs = new Map()
@@ -73,10 +75,14 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
73
75
  const sessionName = generateSessionName(workflow.id, executionId)
74
76
 
75
77
  // Build prompt
76
- const skillCommands = skillNames.map(name => `/${name}`).join(', then ')
78
+ const primary = getActivePrimary()
79
+ const formatSkill = primary && typeof primary.formatSkillInvocation === 'function'
80
+ ? primary.formatSkillInvocation.bind(primary)
81
+ : (name) => `/${name}`
82
+ const skillCommands = skillNames.map(name => formatSkill(name)).join(', then ')
77
83
 
78
84
  const rolePrefix = options.role
79
- ? `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`
85
+ ? `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`
80
86
  : ''
81
87
 
82
88
  const revisionContext = options.revisionFeedback
@@ -115,12 +121,19 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
115
121
  activeSessions.delete(sessionName)
116
122
  }
117
123
 
118
- // Build the LLM command
119
- if (!config.LLM_COMMAND) {
120
- throw new Error('LLM_COMMAND is not configured. Set LLM_COMMAND in minion.env')
124
+ // Build the LLM command (prefer plugin system, fall back to LLM_COMMAND)
125
+ let llmCommand
126
+ const primary = getActivePrimary()
127
+ if (primary && typeof primary.buildShellInvocation === 'function') {
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')
121
136
  }
122
- const escapedPrompt = prompt.replace(/'/g, "''")
123
- const llmCommand = config.LLM_COMMAND.replace(/\{prompt\}/g, escapedPrompt)
124
137
 
125
138
  // PATH, HOME, USERPROFILE, and minion secrets are already set in
126
139
  // process.env at server startup, so child processes inherit them automatically.