@geekbeer/minion 3.13.0 → 3.16.1
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-node-executor.js +81 -0
- package/core/lib/dag-step-poller.js +282 -0
- package/docs/api-reference.md +28 -3
- package/docs/environment-setup.md +9 -0
- package/linux/lib/process-manager.js +10 -0
- package/linux/server.js +3 -0
- package/package.json +1 -1
- package/rules/core.md +24 -0
- package/rules/windows.md +72 -0
- package/win/lib/process-manager.js +62 -0
- package/win/routes/terminal.js +14 -1
- package/win/server.js +9 -4
- package/win/wsl-session-server.js +45 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG Node Executor
|
|
3
|
+
*
|
|
4
|
+
* Handles the execution lifecycle of a single DAG node.
|
|
5
|
+
* Responsible for:
|
|
6
|
+
* - Extracting structured output_data from skill execution results
|
|
7
|
+
* - Reporting completion to HQ via /api/dag/minion/node-complete
|
|
8
|
+
*
|
|
9
|
+
* Called by the post-execution hook in skills.js when a DAG node skill completes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { reportNodeComplete } = require('./dag-step-poller')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse structured output_data from a skill's execution output.
|
|
16
|
+
*
|
|
17
|
+
* Convention: Skills produce structured output via a "## Output Data" section
|
|
18
|
+
* in their execution report, containing a JSON code block.
|
|
19
|
+
*
|
|
20
|
+
* Example:
|
|
21
|
+
* ```
|
|
22
|
+
* ## Output Data
|
|
23
|
+
* ```json
|
|
24
|
+
* { "results": [...], "count": 5 }
|
|
25
|
+
* ```
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* If no structured output is found, wraps the raw summary as { _raw: "..." }
|
|
29
|
+
*/
|
|
30
|
+
function extractOutputData(summary, details) {
|
|
31
|
+
const fullText = [summary, details].filter(Boolean).join('\n\n')
|
|
32
|
+
|
|
33
|
+
// Try to find ## Output Data section with JSON
|
|
34
|
+
const outputDataMatch = fullText.match(
|
|
35
|
+
/##\s*Output\s*Data\s*\n+```(?:json)?\s*\n([\s\S]*?)\n```/i
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (outputDataMatch) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(outputDataMatch[1].trim())
|
|
41
|
+
} catch {
|
|
42
|
+
console.warn('[DagNodeExecutor] Failed to parse Output Data JSON, using raw')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fallback: try to parse the entire summary as JSON
|
|
47
|
+
if (summary) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(summary)
|
|
50
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
51
|
+
return parsed
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Not JSON, use raw
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { _raw: fullText || '' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Report DAG node completion with extracted output_data.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} nodeExecutionId - The dag_node_execution ID
|
|
65
|
+
* @param {string} status - 'completed' or 'failed'
|
|
66
|
+
* @param {string} summary - Execution summary text
|
|
67
|
+
* @param {string} details - Execution details text
|
|
68
|
+
*/
|
|
69
|
+
async function reportDagNodeComplete(nodeExecutionId, status, summary, details) {
|
|
70
|
+
const outputSummary = [summary, details].filter(Boolean).join('\n\n')
|
|
71
|
+
const outputData = status === 'completed'
|
|
72
|
+
? extractOutputData(summary, details)
|
|
73
|
+
: {}
|
|
74
|
+
|
|
75
|
+
return reportNodeComplete(nodeExecutionId, status, outputData, outputSummary)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
extractOutputData,
|
|
80
|
+
reportDagNodeComplete,
|
|
81
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG Step Poller
|
|
3
|
+
*
|
|
4
|
+
* Polling daemon for DAG workflow execution.
|
|
5
|
+
* Similar to step-poller.js but with key differences:
|
|
6
|
+
* - Polls /api/dag/minion/pending-nodes instead of /api/minion/pending-steps
|
|
7
|
+
* - Supports concurrent node execution (up to MAX_CONCURRENT)
|
|
8
|
+
* - Reports completion to /api/dag/minion/node-complete
|
|
9
|
+
* - Injects input_data into skill context
|
|
10
|
+
*
|
|
11
|
+
* Feature flag: only starts when DAG_ENGINE_ENABLED=true
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { config, isHqConfigured } = require('../config')
|
|
15
|
+
const api = require('../api')
|
|
16
|
+
const variableStore = require('../stores/variable-store')
|
|
17
|
+
|
|
18
|
+
// Polling interval: 30 seconds (matches step-poller)
|
|
19
|
+
const POLL_INTERVAL_MS = 30_000
|
|
20
|
+
|
|
21
|
+
// Maximum concurrent DAG nodes this minion can execute
|
|
22
|
+
const MAX_CONCURRENT = 2
|
|
23
|
+
|
|
24
|
+
// Prevent concurrent poll cycles
|
|
25
|
+
let polling = false
|
|
26
|
+
let pollTimer = null
|
|
27
|
+
let lastPollAt = null
|
|
28
|
+
|
|
29
|
+
// Track active node executions: nodeExecId -> Promise
|
|
30
|
+
const activeNodes = new Map()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Send request to HQ's DAG API endpoints.
|
|
34
|
+
*/
|
|
35
|
+
async function dagRequest(endpoint, options = {}) {
|
|
36
|
+
if (!isHqConfigured()) {
|
|
37
|
+
return { skipped: true, reason: 'HQ not configured' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const url = `${config.HQ_URL}/api/dag/minion${endpoint}`
|
|
41
|
+
const resp = await fetch(url, {
|
|
42
|
+
...options,
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'Authorization': `Bearer ${config.API_TOKEN}`,
|
|
46
|
+
...(options.headers || {}),
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!resp.ok) {
|
|
51
|
+
const err = new Error(`DAG API ${endpoint} failed: ${resp.status}`)
|
|
52
|
+
err.statusCode = resp.status
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return resp.json()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Poll HQ for pending DAG nodes and execute them.
|
|
61
|
+
*/
|
|
62
|
+
async function pollOnce() {
|
|
63
|
+
if (!isHqConfigured()) return
|
|
64
|
+
if (polling) return
|
|
65
|
+
|
|
66
|
+
polling = true
|
|
67
|
+
try {
|
|
68
|
+
const data = await dagRequest('/pending-nodes')
|
|
69
|
+
|
|
70
|
+
if (!data.nodes || data.nodes.length === 0) return
|
|
71
|
+
|
|
72
|
+
console.log(`[DagPoller] Found ${data.nodes.length} pending node(s), active: ${activeNodes.size}/${MAX_CONCURRENT}`)
|
|
73
|
+
|
|
74
|
+
for (const node of data.nodes) {
|
|
75
|
+
if (activeNodes.size >= MAX_CONCURRENT) break
|
|
76
|
+
if (activeNodes.has(node.node_execution_id)) continue
|
|
77
|
+
|
|
78
|
+
const promise = executeNode(node)
|
|
79
|
+
.catch(err => {
|
|
80
|
+
console.error(`[DagPoller] Node ${node.node_id} execution error: ${err.message}`)
|
|
81
|
+
})
|
|
82
|
+
.finally(() => {
|
|
83
|
+
activeNodes.delete(node.node_execution_id)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
activeNodes.set(node.node_execution_id, promise)
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
|
|
90
|
+
console.log('[DagPoller] HQ unreachable, will retry next cycle')
|
|
91
|
+
} else {
|
|
92
|
+
console.error(`[DagPoller] Poll error: ${err.message}`)
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
polling = false
|
|
96
|
+
lastPollAt = new Date().toISOString()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Execute a single pending DAG node:
|
|
102
|
+
* 1. Claim the node
|
|
103
|
+
* 2. Fetch the skill from HQ
|
|
104
|
+
* 3. Run the skill locally (with input_data injected)
|
|
105
|
+
* 4. Report completion with output_data
|
|
106
|
+
*/
|
|
107
|
+
async function executeNode(node) {
|
|
108
|
+
const {
|
|
109
|
+
node_execution_id,
|
|
110
|
+
execution_id,
|
|
111
|
+
dag_workflow_name,
|
|
112
|
+
node_id,
|
|
113
|
+
scope_path,
|
|
114
|
+
skill_version_id,
|
|
115
|
+
skill_name: resolvedSkillName,
|
|
116
|
+
assigned_role,
|
|
117
|
+
input_data,
|
|
118
|
+
revision_feedback,
|
|
119
|
+
} = node
|
|
120
|
+
|
|
121
|
+
console.log(
|
|
122
|
+
`[DagPoller] Executing node "${node_id}" of DAG "${dag_workflow_name}" ` +
|
|
123
|
+
`(skill: ${resolvedSkillName || skill_version_id}, scope: "${scope_path || 'root'}", role: ${assigned_role})`
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// 1. Claim the node
|
|
128
|
+
try {
|
|
129
|
+
await dagRequest('/claim-node', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
body: JSON.stringify({ node_execution_id }),
|
|
132
|
+
})
|
|
133
|
+
} catch (claimErr) {
|
|
134
|
+
if (claimErr.statusCode === 409) {
|
|
135
|
+
console.log(`[DagPoller] Node ${node_id} already claimed, skipping`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
throw claimErr
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Resolve skill name (prefer pre-resolved from pending-nodes, fall back to API)
|
|
142
|
+
const skillName = resolvedSkillName || await resolveSkillName(skill_version_id)
|
|
143
|
+
if (!skillName) {
|
|
144
|
+
console.error(`[DagPoller] Could not resolve skill name for version ${skill_version_id}`)
|
|
145
|
+
await reportNodeComplete(node_execution_id, 'failed', null, 'Could not resolve skill name')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Fetch the skill
|
|
150
|
+
if (skillName) {
|
|
151
|
+
try {
|
|
152
|
+
const minionVars = variableStore.getAll('variables')
|
|
153
|
+
const mergedVars = { ...minionVars }
|
|
154
|
+
const varsParam = Object.keys(mergedVars).length > 0
|
|
155
|
+
? `?vars=${Buffer.from(JSON.stringify(mergedVars)).toString('base64')}`
|
|
156
|
+
: ''
|
|
157
|
+
const fetchUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/fetch/${encodeURIComponent(skillName)}${varsParam}`
|
|
158
|
+
await fetch(fetchUrl, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Authorization': `Bearer ${config.API_TOKEN}` },
|
|
161
|
+
})
|
|
162
|
+
} catch (fetchErr) {
|
|
163
|
+
console.error(`[DagPoller] Skill fetch error: ${fetchErr.message}`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 4. Run the skill with input_data context
|
|
168
|
+
const runPayload = {
|
|
169
|
+
skill_name: skillName,
|
|
170
|
+
execution_id,
|
|
171
|
+
workflow_name: dag_workflow_name,
|
|
172
|
+
role: assigned_role,
|
|
173
|
+
// DAG-specific: inject input_data as context
|
|
174
|
+
dag_node_id: node_id,
|
|
175
|
+
dag_input_data: input_data,
|
|
176
|
+
dag_node_execution_id: node_execution_id,
|
|
177
|
+
}
|
|
178
|
+
if (revision_feedback) {
|
|
179
|
+
runPayload.revision_feedback = revision_feedback
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const runUrl = `http://localhost:${config.AGENT_PORT || 8080}/api/skills/run`
|
|
183
|
+
const runResp = await fetch(runUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/json',
|
|
187
|
+
'Authorization': `Bearer ${config.API_TOKEN}`,
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify(runPayload),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (!runResp.ok) {
|
|
193
|
+
const errData = await runResp.json().catch(() => ({}))
|
|
194
|
+
console.error(`[DagPoller] Skill run failed: ${errData.error || runResp.status}`)
|
|
195
|
+
await reportNodeComplete(
|
|
196
|
+
node_execution_id,
|
|
197
|
+
'failed',
|
|
198
|
+
null,
|
|
199
|
+
`Failed to start skill: ${errData.error || 'unknown error'}`
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const runData = await runResp.json()
|
|
205
|
+
console.log(
|
|
206
|
+
`[DagPoller] Skill "${skillName}" started for node "${node_id}" ` +
|
|
207
|
+
`(session: ${runData.session_name}). Completion reported by post-execution hook.`
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
// Note: The post-execution hook in skills.js needs to be extended to handle DAG nodes.
|
|
211
|
+
// For now, we wait for the skill to complete and then report via the session monitor.
|
|
212
|
+
// This will be handled by dag-node-executor.js which watches the session.
|
|
213
|
+
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(`[DagPoller] Failed to execute node ${node_id}: ${err.message}`)
|
|
216
|
+
try {
|
|
217
|
+
await reportNodeComplete(node_execution_id, 'failed', null, err.message)
|
|
218
|
+
} catch {
|
|
219
|
+
// best-effort
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve skill_version_id to skill name via HQ API.
|
|
226
|
+
*/
|
|
227
|
+
async function resolveSkillName(skillVersionId) {
|
|
228
|
+
if (!skillVersionId) return null
|
|
229
|
+
try {
|
|
230
|
+
// Use existing skill fetch pattern — the skill name is returned by pending-nodes
|
|
231
|
+
// But if not available, we need to resolve it
|
|
232
|
+
const data = await api.request(`/skill-version/${skillVersionId}`)
|
|
233
|
+
return data?.skill_name || null
|
|
234
|
+
} catch {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Report node completion to HQ.
|
|
241
|
+
*/
|
|
242
|
+
async function reportNodeComplete(nodeExecutionId, status, outputData, outputSummary) {
|
|
243
|
+
return dagRequest('/node-complete', {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
node_execution_id: nodeExecutionId,
|
|
247
|
+
status,
|
|
248
|
+
output_data: outputData || {},
|
|
249
|
+
output_summary: outputSummary || null,
|
|
250
|
+
}),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function start() {
|
|
255
|
+
if (!isHqConfigured()) {
|
|
256
|
+
console.log('[DagPoller] HQ not configured, DAG poller disabled')
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setTimeout(() => pollOnce(), 7000) // Slightly delayed after step-poller
|
|
261
|
+
pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
|
|
262
|
+
console.log(`[DagPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s, max concurrent: ${MAX_CONCURRENT})`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stop() {
|
|
266
|
+
if (pollTimer) {
|
|
267
|
+
clearInterval(pollTimer)
|
|
268
|
+
pollTimer = null
|
|
269
|
+
console.log('[DagPoller] Stopped')
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getStatus() {
|
|
274
|
+
return {
|
|
275
|
+
running: pollTimer !== null,
|
|
276
|
+
last_poll_at: lastPollAt,
|
|
277
|
+
active_nodes: activeNodes.size,
|
|
278
|
+
max_concurrent: MAX_CONCURRENT,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = { start, stop, pollOnce, getStatus, reportNodeComplete }
|
package/docs/api-reference.md
CHANGED
|
@@ -54,9 +54,9 @@ Outcome values: `success`, `failure`, `partial`
|
|
|
54
54
|
|
|
55
55
|
| Method | Endpoint | Description |
|
|
56
56
|
|--------|----------|-------------|
|
|
57
|
-
| GET | `/api/terminal/sessions` | List
|
|
57
|
+
| GET | `/api/terminal/sessions` | List sessions (CMD + WSL merged) |
|
|
58
58
|
| POST | `/api/terminal/send` | Send keys. Body: `{session, input?, enter?, special?}` |
|
|
59
|
-
| POST | `/api/terminal/create` | Create session. Body: `{name?, command?}` |
|
|
59
|
+
| POST | `/api/terminal/create` | Create session. Body: `{name?, command?, type?, get_or_create?}` |
|
|
60
60
|
| POST | `/api/terminal/kill` | Kill session. Body: `{session}` |
|
|
61
61
|
| GET | `/api/terminal/capture` | Capture pane content. Query: `?session=&lines=100` |
|
|
62
62
|
| GET | `/api/terminal/ttyd/status` | ttyd process status |
|
|
@@ -64,6 +64,31 @@ Outcome values: `success`, `failure`, `partial`
|
|
|
64
64
|
| POST | `/api/terminal/ttyd/stop` | Stop ttyd for session. Body: `{session}` |
|
|
65
65
|
| POST | `/api/terminal/ttyd/stop-all` | Stop all ttyd processes |
|
|
66
66
|
|
|
67
|
+
#### WSL セッション(Windows ミニオン限定)
|
|
68
|
+
|
|
69
|
+
WSL 内でコマンドを実行するには `type: "wsl"` を指定する。セッション名は `wsl-` prefix が自動付与される。
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# WSL セッション作成(既存があれば再利用)
|
|
73
|
+
curl -X POST http://localhost:8080/api/terminal/create \
|
|
74
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
75
|
+
-H "Content-Type: application/json" \
|
|
76
|
+
-d '{"name": "wsl-dev", "type": "wsl", "get_or_create": true}'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| パラメータ | 型 | 説明 |
|
|
80
|
+
|-----------|-----|------|
|
|
81
|
+
| `type` | string | `"wsl"` で WSL セッションを作成 |
|
|
82
|
+
| `get_or_create` | boolean | `true` の場合、同名の既存セッションがあれば再利用(レスポンスに `reused: true`) |
|
|
83
|
+
|
|
84
|
+
WSL セッションの上限は **5つ**。上限に達すると `429` を返す。不要なセッションを `kill` してから再作成すること。
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# WSL サーバー稼働状態の確認
|
|
88
|
+
curl http://localhost:8080/api/terminal/wsl/status \
|
|
89
|
+
-H "Authorization: Bearer $API_TOKEN"
|
|
90
|
+
```
|
|
91
|
+
|
|
67
92
|
### Files
|
|
68
93
|
|
|
69
94
|
Files are stored in `~/files/`. Max upload size: 50MB.
|
|
@@ -543,7 +568,7 @@ Note: 既読メールは受信後90日で自動削除される。未読メール
|
|
|
543
568
|
| GET | `/api/commands` | List available whitelisted commands |
|
|
544
569
|
| POST | `/api/command` | Execute command. Body: `{command}` |
|
|
545
570
|
|
|
546
|
-
Available commands: `restart-agent`, `update-agent`, `restart-display`, `status-services`
|
|
571
|
+
Available commands: `restart-agent`, `update-agent`, `restart-display`, `restart-all`, `status-services`
|
|
547
572
|
|
|
548
573
|
---
|
|
549
574
|
|
|
@@ -232,6 +232,14 @@ curl -X POST http://localhost:8080/api/chat \
|
|
|
232
232
|
| 7682 | WSL session server (HTTP API) | localhost のみ |
|
|
233
233
|
| 7683 | WSL session server (WebSocket) | localhost のみ、ttyd プロトコル |
|
|
234
234
|
|
|
235
|
+
### セッション管理
|
|
236
|
+
|
|
237
|
+
- **セッション再利用**: `get_or_create: true` を指定すると、同名の既存セッションがあれば再利用される。毎回新しいセッションを作らないこと。
|
|
238
|
+
- **固定名**: `wsl-dev`, `wsl-build` など目的を示す名前を使う。名前を省略するとタイムスタンプ名が生成され再利用できない。
|
|
239
|
+
- **上限**: WSL セッションは最大 5 つ。上限に達すると作成が拒否される。
|
|
240
|
+
- **自動クリーンアップ**: 完了済みセッションは 5 分後に自動削除される。
|
|
241
|
+
- **手動クリーンアップ**: 不要なセッションは `POST /api/terminal/kill` で終了する。
|
|
242
|
+
|
|
235
243
|
### トラブルシューティング
|
|
236
244
|
|
|
237
245
|
| 問題 | 原因 | 対処 |
|
|
@@ -239,6 +247,7 @@ curl -X POST http://localhost:8080/api/chat \
|
|
|
239
247
|
| WSL session server is not running | ユーザーが未ログイン | RDP/コンソールでログイン |
|
|
240
248
|
| WSL not detected during setup | WSL 未インストール | `wsl --install` を実行後 `minion-cli setup` を再実行 |
|
|
241
249
|
| Connection refused on port 7682 | サーバー異常終了 | `schtasks /Run /TN "MinionWSL"` で再起動 |
|
|
250
|
+
| WSL session limit reached | 5 セッション上限 | `GET /api/terminal/sessions` で確認し不要なセッションを `kill` |
|
|
242
251
|
|
|
243
252
|
---
|
|
244
253
|
|
|
@@ -63,6 +63,11 @@ function buildAllowedCommands(procMgr) {
|
|
|
63
63
|
description: 'Restart Xvfb, Fluxbox, x11vnc and noVNC services',
|
|
64
64
|
command: `${SUDO}systemctl restart xvfb fluxbox x11vnc novnc`,
|
|
65
65
|
}
|
|
66
|
+
commands['restart-all'] = {
|
|
67
|
+
description: 'Restart all services (display stack + agent)',
|
|
68
|
+
command: `${SUDO}systemctl restart xvfb fluxbox x11vnc novnc && ${SUDO}systemctl restart minion-agent`,
|
|
69
|
+
deferred: true,
|
|
70
|
+
}
|
|
66
71
|
commands['status-services'] = {
|
|
67
72
|
description: 'Check status of all services',
|
|
68
73
|
command: 'systemctl status minion-agent xvfb fluxbox x11vnc novnc --no-pager',
|
|
@@ -87,6 +92,11 @@ function buildAllowedCommands(procMgr) {
|
|
|
87
92
|
description: 'Restart Xvfb, x11vnc and noVNC services',
|
|
88
93
|
command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc`,
|
|
89
94
|
}
|
|
95
|
+
commands['restart-all'] = {
|
|
96
|
+
description: 'Restart all services (display stack + agent)',
|
|
97
|
+
command: `${SUDO}supervisorctl restart xvfb fluxbox x11vnc novnc && ${SUDO}supervisorctl restart minion-agent`,
|
|
98
|
+
deferred: true,
|
|
99
|
+
}
|
|
90
100
|
commands['status-services'] = {
|
|
91
101
|
description: 'Check status of all services',
|
|
92
102
|
command: `${SUDO}supervisorctl status`,
|
package/linux/server.js
CHANGED
|
@@ -58,6 +58,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
|
|
|
58
58
|
|
|
59
59
|
// Pull-model daemons (from core/)
|
|
60
60
|
const stepPoller = require('../core/lib/step-poller')
|
|
61
|
+
const dagStepPoller = require('../core/lib/dag-step-poller')
|
|
61
62
|
const revisionWatcher = require('../core/lib/revision-watcher')
|
|
62
63
|
const reflectionScheduler = require('../core/lib/reflection-scheduler')
|
|
63
64
|
const threadWatcher = require('../core/lib/thread-watcher')
|
|
@@ -122,6 +123,7 @@ async function shutdown(signal) {
|
|
|
122
123
|
|
|
123
124
|
// Stop pollers, runners, and scheduler
|
|
124
125
|
stepPoller.stop()
|
|
126
|
+
dagStepPoller.stop()
|
|
125
127
|
revisionWatcher.stop()
|
|
126
128
|
reflectionScheduler.stop()
|
|
127
129
|
threadWatcher.stop()
|
|
@@ -391,6 +393,7 @@ async function start() {
|
|
|
391
393
|
|
|
392
394
|
// Start Pull-model daemons
|
|
393
395
|
stepPoller.start()
|
|
396
|
+
dagStepPoller.start()
|
|
394
397
|
revisionWatcher.start()
|
|
395
398
|
threadWatcher.start(runQuickLlmCall)
|
|
396
399
|
} else {
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -40,6 +40,30 @@ Minion
|
|
|
40
40
|
|
|
41
41
|
API の詳細仕様は `~/.minion/docs/api-reference.md` の「Email」セクションを参照。
|
|
42
42
|
|
|
43
|
+
## Terminal Session Management
|
|
44
|
+
|
|
45
|
+
ターミナルセッション(`/api/terminal/*`)を使用する際は以下のルールに従うこと。
|
|
46
|
+
|
|
47
|
+
### セッション再利用の原則
|
|
48
|
+
|
|
49
|
+
- **新しいセッションを作る前に、既存セッションを確認する。**
|
|
50
|
+
```bash
|
|
51
|
+
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8080/api/terminal/sessions
|
|
52
|
+
```
|
|
53
|
+
- 目的に合う既存セッション(未完了で再利用可能なもの)があれば、そのセッションに `send` でコマンドを送る。
|
|
54
|
+
- 新規作成が必要な場合は、**目的を示す固定名**を付ける(例: `dev`, `build`, `test`)。タイムスタンプ名(`session-1234567890`)は避ける。
|
|
55
|
+
|
|
56
|
+
### クリーンアップ
|
|
57
|
+
|
|
58
|
+
- 作業が完了したセッションは `POST /api/terminal/kill` で終了する。
|
|
59
|
+
- 一時的なコマンド実行(`command` 引数付きで作成したセッション)は、完了確認後に必ず kill する。
|
|
60
|
+
- セッションを放置しない。使い終わったら片付ける。
|
|
61
|
+
|
|
62
|
+
### セッション数の制限
|
|
63
|
+
|
|
64
|
+
- 同時に保持するセッションは **最大3つ** を目安にする。
|
|
65
|
+
- それ以上必要な場合は、不要なセッションを先に kill してから作成する。
|
|
66
|
+
|
|
43
67
|
## Available Tools
|
|
44
68
|
|
|
45
69
|
### CLI
|
package/rules/windows.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Windows Minion
|
|
2
|
+
|
|
3
|
+
Windows ミニオン固有のルール。
|
|
4
|
+
|
|
5
|
+
## WSL セッション管理
|
|
6
|
+
|
|
7
|
+
Windows ミニオンでは WSL(Windows Subsystem for Linux)内の Docker やリポジトリ操作のために WSL セッションを使用できる。
|
|
8
|
+
|
|
9
|
+
### WSL セッションの使い方
|
|
10
|
+
|
|
11
|
+
#### ターミナルセッション
|
|
12
|
+
|
|
13
|
+
WSL 内でコマンドを実行するには `type: "wsl"` を指定してセッションを作成する:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# WSL セッション作成(固定名を付けること)
|
|
17
|
+
curl -X POST http://localhost:8080/api/terminal/create \
|
|
18
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
19
|
+
-H "Content-Type: application/json" \
|
|
20
|
+
-d '{"name": "wsl-dev", "type": "wsl"}'
|
|
21
|
+
|
|
22
|
+
# コマンド送信
|
|
23
|
+
curl -X POST http://localhost:8080/api/terminal/send \
|
|
24
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
25
|
+
-H "Content-Type: application/json" \
|
|
26
|
+
-d '{"session": "wsl-dev", "input": "docker compose up -d", "enter": true}'
|
|
27
|
+
|
|
28
|
+
# 出力確認
|
|
29
|
+
curl "http://localhost:8080/api/terminal/capture?session=wsl-dev&lines=50" \
|
|
30
|
+
-H "Authorization: Bearer $API_TOKEN"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### チャット(WSL モード)
|
|
34
|
+
|
|
35
|
+
チャット API に `wsl_mode: true` を追加すると、Claude Code CLI がユーザーセッションで実行され WSL コマンドを直接使用できる:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
curl -X POST http://localhost:8080/api/chat \
|
|
39
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
40
|
+
-H "Content-Type: application/json" \
|
|
41
|
+
-d '{"message": "WSL内でリポジトリをクローンしてDockerを起動して", "wsl_mode": true}'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### セッション管理ルール
|
|
45
|
+
|
|
46
|
+
#### 再利用を徹底する
|
|
47
|
+
|
|
48
|
+
- **WSL セッションの新規作成前に、必ず既存セッションを確認する。**
|
|
49
|
+
```bash
|
|
50
|
+
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8080/api/terminal/sessions
|
|
51
|
+
```
|
|
52
|
+
レスポンスの `type: "wsl"` かつ `completed: false` のセッションがあれば再利用する。
|
|
53
|
+
- **固定名を使う。** `wsl-dev`, `wsl-build`, `wsl-docker` など目的を示す名前を付ける。
|
|
54
|
+
名前を省略すると `wsl-session-{timestamp}` が自動生成され、再利用できなくなる。
|
|
55
|
+
- **1つの WSL セッションで複数コマンドを順次実行する。** コマンドごとに新しいセッションを作らない。
|
|
56
|
+
|
|
57
|
+
#### クリーンアップ
|
|
58
|
+
|
|
59
|
+
- WSL 作業が完了したら `POST /api/terminal/kill` でセッションを終了する。
|
|
60
|
+
- WSL セッションは最大 **5つ** まで。上限に達すると新規作成が拒否される。
|
|
61
|
+
- 完了済み(`completed: true`)のセッションは自動的にクリーンアップされる。
|
|
62
|
+
|
|
63
|
+
#### WSL セッションサーバーの確認
|
|
64
|
+
|
|
65
|
+
WSL セッションが使えない場合は、まずサーバーの稼働状態を確認する:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl http://localhost:8080/api/terminal/wsl/status \
|
|
69
|
+
-H "Authorization: Bearer $API_TOKEN"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`running: false` の場合、ターゲットユーザーがログインしていない可能性がある。
|
|
@@ -220,6 +220,62 @@ function buildRestartScript(_nssmPath, agentPort, apiToken) {
|
|
|
220
220
|
return scriptPath
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Generate a temporary PowerShell script for restarting ALL services:
|
|
225
|
+
* 1. Restart display services (VNC + websockify)
|
|
226
|
+
* 2. Graceful shutdown of agent via HTTP API
|
|
227
|
+
* 3. Restart agent service via NSSM
|
|
228
|
+
* 4. Remove the temporary updater service (self-cleanup)
|
|
229
|
+
*
|
|
230
|
+
* @param {string} _nssmPath - Absolute path to nssm.exe
|
|
231
|
+
* @param {number} agentPort - The agent's HTTP port
|
|
232
|
+
* @param {string} apiToken - The agent's API token
|
|
233
|
+
* @returns {string} - Path to the generated restart script (.ps1)
|
|
234
|
+
*/
|
|
235
|
+
function buildRestartAllScript(_nssmPath, agentPort, apiToken) {
|
|
236
|
+
const dataDir = path.join(os.homedir(), '.minion')
|
|
237
|
+
const homeDir = os.homedir()
|
|
238
|
+
const scriptPath = path.join(dataDir, 'restart-all.ps1')
|
|
239
|
+
const logDir = path.join(dataDir, 'logs')
|
|
240
|
+
const logPath = path.join(logDir, 'restart-all.log')
|
|
241
|
+
|
|
242
|
+
const gracefulStop = buildGracefulStopBlock(agentPort, apiToken)
|
|
243
|
+
|
|
244
|
+
const ps1 = [
|
|
245
|
+
`$env:USERPROFILE = '${homeDir}'`,
|
|
246
|
+
`$env:HOME = '${homeDir}'`,
|
|
247
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
248
|
+
`$nssm = '${path.join(dataDir, 'nssm.exe')}'`,
|
|
249
|
+
`$logFile = '${logPath}'`,
|
|
250
|
+
`function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append $logFile }`,
|
|
251
|
+
`Log 'Restart-all started'`,
|
|
252
|
+
`try {`,
|
|
253
|
+
` Log 'Restarting display services...'`,
|
|
254
|
+
` try { & $nssm restart minion-vnc 2>&1 | ForEach-Object { Log "minion-vnc: $_" } } catch { Log "minion-vnc restart failed: $_" }`,
|
|
255
|
+
` try { & $nssm restart minion-websockify 2>&1 | ForEach-Object { Log "minion-websockify: $_" } } catch { Log "minion-websockify restart failed: $_" }`,
|
|
256
|
+
` Log 'Display services restarted'`,
|
|
257
|
+
` Log 'Requesting graceful shutdown of agent...'`,
|
|
258
|
+
gracefulStop,
|
|
259
|
+
` Log 'Restarting agent service via NSSM...'`,
|
|
260
|
+
` & $nssm restart minion-agent`,
|
|
261
|
+
` Log 'Restart-all completed successfully'`,
|
|
262
|
+
`} catch {`,
|
|
263
|
+
` Log "Restart-all failed: $_"`,
|
|
264
|
+
` Log 'Attempting to start agent service anyway...'`,
|
|
265
|
+
` & $nssm start minion-agent`,
|
|
266
|
+
`} finally {`,
|
|
267
|
+
` Log 'Cleaning up updater service...'`,
|
|
268
|
+
` & $nssm stop minion-update confirm 2>$null`,
|
|
269
|
+
` & $nssm remove minion-update confirm 2>$null`,
|
|
270
|
+
`}`,
|
|
271
|
+
].join('\n')
|
|
272
|
+
|
|
273
|
+
try { fs.mkdirSync(dataDir, { recursive: true }) } catch { /* exists */ }
|
|
274
|
+
fs.writeFileSync(scriptPath, ps1, 'utf-8')
|
|
275
|
+
|
|
276
|
+
return scriptPath
|
|
277
|
+
}
|
|
278
|
+
|
|
223
279
|
/**
|
|
224
280
|
* Build allowed commands for NSSM-based service management.
|
|
225
281
|
*
|
|
@@ -271,6 +327,12 @@ function buildAllowedCommands(_procMgr, agentConfig = {}) {
|
|
|
271
327
|
command: `"${nssmPath}" restart minion-vnc & "${nssmPath}" restart minion-websockify`,
|
|
272
328
|
}
|
|
273
329
|
|
|
330
|
+
commands['restart-all'] = {
|
|
331
|
+
description: 'Restart all services (display + agent)',
|
|
332
|
+
nssmService: { scriptPath: buildRestartAllScript(nssmPath, agentPort, apiToken) },
|
|
333
|
+
deferred: true,
|
|
334
|
+
}
|
|
335
|
+
|
|
274
336
|
commands['restart-tunnel'] = {
|
|
275
337
|
description: 'Restart Cloudflare tunnel service',
|
|
276
338
|
command: `"${nssmPath}" restart minion-cloudflared`,
|
package/win/routes/terminal.js
CHANGED
|
@@ -316,13 +316,26 @@ async function terminalRoutes(fastify) {
|
|
|
316
316
|
if (type === 'wsl') {
|
|
317
317
|
const sessionName = name || `wsl-session-${Date.now()}`
|
|
318
318
|
const wslName = sessionName.startsWith('wsl-') ? sessionName : `wsl-${sessionName}`
|
|
319
|
+
|
|
320
|
+
// get_or_create: return existing session if it's still alive
|
|
321
|
+
if (request.body.get_or_create) {
|
|
322
|
+
const sessions = await proxyToWsl('GET', '/api/wsl/sessions')
|
|
323
|
+
if (sessions && sessions.success && sessions.sessions) {
|
|
324
|
+
const existing = sessions.sessions.find(s => s.name === wslName && !s.completed)
|
|
325
|
+
if (existing) {
|
|
326
|
+
console.log(`[Terminal] Reusing existing WSL session '${wslName}'`)
|
|
327
|
+
return { success: true, session: wslName, message: `Reusing existing WSL session '${wslName}'`, reused: true }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
319
332
|
console.log(`[Terminal] Creating WSL session '${wslName}' — proxying to WSL server`)
|
|
320
333
|
const result = await proxyToWsl('POST', '/api/wsl/create', { name: wslName, command })
|
|
321
334
|
if (!result) {
|
|
322
335
|
reply.code(503)
|
|
323
336
|
return { success: false, error: 'WSL session server is not running. The target user must be logged in for WSL sessions.' }
|
|
324
337
|
}
|
|
325
|
-
reply.code(result.success ? 200 : 500)
|
|
338
|
+
reply.code(result.success ? 200 : (result.error?.includes('limit') ? 429 : 500))
|
|
326
339
|
return result
|
|
327
340
|
}
|
|
328
341
|
|
package/win/server.js
CHANGED
|
@@ -34,6 +34,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
|
|
|
34
34
|
|
|
35
35
|
// Pull-model daemons (from core/)
|
|
36
36
|
const stepPoller = require('../core/lib/step-poller')
|
|
37
|
+
const dagStepPoller = require('../core/lib/dag-step-poller')
|
|
37
38
|
const revisionWatcher = require('../core/lib/revision-watcher')
|
|
38
39
|
const reflectionScheduler = require('../core/lib/reflection-scheduler')
|
|
39
40
|
const threadWatcher = require('../core/lib/thread-watcher')
|
|
@@ -94,6 +95,7 @@ async function shutdown(signal) {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
stepPoller.stop()
|
|
98
|
+
dagStepPoller.stop()
|
|
97
99
|
revisionWatcher.stop()
|
|
98
100
|
reflectionScheduler.stop()
|
|
99
101
|
threadWatcher.stop()
|
|
@@ -144,10 +146,12 @@ function syncBundledRules() {
|
|
|
144
146
|
try {
|
|
145
147
|
if (!fs.existsSync(bundledRulesDir)) return
|
|
146
148
|
fs.mkdirSync(targetRulesDir, { recursive: true })
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
fs.
|
|
150
|
-
|
|
149
|
+
for (const ruleFile of ['core.md', 'windows.md']) {
|
|
150
|
+
const src = path.join(bundledRulesDir, ruleFile)
|
|
151
|
+
if (fs.existsSync(src)) {
|
|
152
|
+
fs.copyFileSync(src, path.join(targetRulesDir, ruleFile))
|
|
153
|
+
console.log(`[Rules] Synced: ${ruleFile}`)
|
|
154
|
+
}
|
|
151
155
|
}
|
|
152
156
|
for (const legacy of ['minion.md', 'role-pm.md', 'role-engineer.md']) {
|
|
153
157
|
const legacyPath = path.join(targetRulesDir, legacy)
|
|
@@ -356,6 +360,7 @@ async function start() {
|
|
|
356
360
|
|
|
357
361
|
// Start Pull-model daemons
|
|
358
362
|
stepPoller.start()
|
|
363
|
+
dagStepPoller.start()
|
|
359
364
|
revisionWatcher.start()
|
|
360
365
|
threadWatcher.start(runQuickLlmCall)
|
|
361
366
|
} else {
|
|
@@ -29,6 +29,9 @@ const DATA_DIR = path.join(HOME_DIR, '.minion')
|
|
|
29
29
|
const TOKEN_FILE = path.join(DATA_DIR, '.wsl-session-token')
|
|
30
30
|
const PID_FILE = path.join(DATA_DIR, '.wsl-session.pid')
|
|
31
31
|
|
|
32
|
+
const MAX_WSL_SESSIONS = 5
|
|
33
|
+
const COMPLETED_SESSION_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
34
|
+
|
|
32
35
|
let AUTH_TOKEN = ''
|
|
33
36
|
try {
|
|
34
37
|
AUTH_TOKEN = fs.readFileSync(TOKEN_FILE, 'utf-8').trim()
|
|
@@ -109,6 +112,7 @@ function createWslSession(sessionName, command) {
|
|
|
109
112
|
ptyProcess.onExit(({ exitCode }) => {
|
|
110
113
|
session.completed = true
|
|
111
114
|
session.exitCode = exitCode
|
|
115
|
+
session.completedAt = Date.now()
|
|
112
116
|
for (const ws of session.wsClients) {
|
|
113
117
|
try { ws.close() } catch {}
|
|
114
118
|
}
|
|
@@ -119,6 +123,30 @@ function createWslSession(sessionName, command) {
|
|
|
119
123
|
return session
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Remove completed sessions that have been idle for COMPLETED_SESSION_TTL_MS.
|
|
128
|
+
*/
|
|
129
|
+
function reapCompletedSessions() {
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
for (const [name, session] of activeSessions) {
|
|
132
|
+
if (session.completed && session.completedAt && now - session.completedAt > COMPLETED_SESSION_TTL_MS) {
|
|
133
|
+
activeSessions.delete(name)
|
|
134
|
+
console.log(`[WSL] Reaped completed session '${name}'`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Count active (non-completed) sessions.
|
|
141
|
+
*/
|
|
142
|
+
function activeSessionCount() {
|
|
143
|
+
let count = 0
|
|
144
|
+
for (const [, session] of activeSessions) {
|
|
145
|
+
if (!session.completed) count++
|
|
146
|
+
}
|
|
147
|
+
return count
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
const specialKeyMap = {
|
|
123
151
|
'Enter': '\r', 'Escape': '\x1b', 'Tab': '\t',
|
|
124
152
|
'C-c': '\x03', 'C-d': '\x04', 'C-z': '\x1a',
|
|
@@ -249,10 +277,24 @@ async function startServer() {
|
|
|
249
277
|
if (!/^[\w-]+$/.test(sessionName)) {
|
|
250
278
|
reply.code(400); return { success: false, error: 'Invalid session name' }
|
|
251
279
|
}
|
|
280
|
+
|
|
281
|
+
// Reap completed sessions before checking limits
|
|
282
|
+
reapCompletedSessions()
|
|
283
|
+
|
|
252
284
|
if (activeSessions.has(sessionName)) {
|
|
253
285
|
reply.code(409); return { success: false, error: `Session '${sessionName}' already exists` }
|
|
254
286
|
}
|
|
255
287
|
|
|
288
|
+
if (activeSessionCount() >= MAX_WSL_SESSIONS) {
|
|
289
|
+
reply.code(429)
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
error: `WSL session limit reached (max ${MAX_WSL_SESSIONS}). Kill unused sessions before creating new ones.`,
|
|
293
|
+
active_sessions: activeSessionCount(),
|
|
294
|
+
max_sessions: MAX_WSL_SESSIONS,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
256
298
|
try {
|
|
257
299
|
createWslSession(sessionName, command)
|
|
258
300
|
return { success: true, session: sessionName, message: `WSL session '${sessionName}' created` }
|
|
@@ -373,6 +415,9 @@ async function startServer() {
|
|
|
373
415
|
fs.writeFileSync(PID_FILE, String(process.pid))
|
|
374
416
|
} catch {}
|
|
375
417
|
|
|
418
|
+
// Periodically reap completed sessions
|
|
419
|
+
setInterval(reapCompletedSessions, 60 * 1000)
|
|
420
|
+
|
|
376
421
|
return fastify
|
|
377
422
|
}
|
|
378
423
|
|