@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.
@@ -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 }
@@ -54,9 +54,9 @@ Outcome values: `success`, `failure`, `partial`
54
54
 
55
55
  | Method | Endpoint | Description |
56
56
  |--------|----------|-------------|
57
- | GET | `/api/terminal/sessions` | List tmux sessions |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.13.0",
3
+ "version": "3.16.1",
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
@@ -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
@@ -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`,
@@ -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 coreSrc = path.join(bundledRulesDir, 'core.md')
148
- if (fs.existsSync(coreSrc)) {
149
- fs.copyFileSync(coreSrc, path.join(targetRulesDir, 'core.md'))
150
- console.log('[Rules] Synced: core.md')
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