@geekbeer/minion 3.43.0 → 3.49.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,181 @@
1
+ /**
2
+ * Board Task Runner (Windows / WSL固定)
3
+ *
4
+ * Board tasks always run inside WSL on Windows minions. This runner is a
5
+ * thin HTTP proxy to wsl-session-server (which runs in user context and
6
+ * actually invokes wsl.exe + tmux).
7
+ *
8
+ * Why WSL-only:
9
+ * - tmux exists inside WSL but not on Windows native; using tmux unifies
10
+ * session naming/visibility with Linux minions (`tmux ls | grep bt-`).
11
+ * - The Linux toolchain inside WSL is what board task work usually needs.
12
+ * - The chat/workflow native paths remain available for users who want
13
+ * them; only board tasks are pinned to WSL.
14
+ *
15
+ * Session naming: `bt-{taskId.slice(0,8)}` (same as Linux)
16
+ */
17
+
18
+ const path = require('path')
19
+ const fs = require('fs')
20
+ const http = require('http')
21
+ const { config } = require('../core/config')
22
+ const runningTasks = require('../core/lib/running-tasks')
23
+ const { buildBoardTaskInjection } = require('../core/lib/board-task-context')
24
+
25
+ const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
26
+ const TASK_TIMEOUT_MS = 4 * 60 * 60 * 1000
27
+ const POLL_INTERVAL_MS = 5_000
28
+
29
+ function generateSessionName(taskId) {
30
+ if (!taskId) throw new Error('taskId is required')
31
+ return `bt-${String(taskId).substring(0, 8)}`
32
+ }
33
+
34
+ function getWslToken() {
35
+ try {
36
+ const tokenPath = path.join(config.HOME_DIR, '.minion', '.wsl-session-token')
37
+ return fs.readFileSync(tokenPath, 'utf-8').trim()
38
+ } catch { return '' }
39
+ }
40
+
41
+ function proxyToWsl(method, urlPath, body) {
42
+ return new Promise((resolve) => {
43
+ const token = getWslToken()
44
+ const bodyStr = body ? JSON.stringify(body) : ''
45
+ const headers = { 'Authorization': `Bearer ${token}` }
46
+ if (body) {
47
+ headers['Content-Type'] = 'application/json'
48
+ headers['Content-Length'] = Buffer.byteLength(bodyStr)
49
+ }
50
+ const req = http.request({
51
+ hostname: '127.0.0.1',
52
+ port: WSL_PORT,
53
+ path: urlPath,
54
+ method,
55
+ headers,
56
+ timeout: 30_000,
57
+ }, (res) => {
58
+ let data = ''
59
+ res.on('data', (c) => { data += c })
60
+ res.on('end', () => {
61
+ try { resolve(JSON.parse(data)) } catch { resolve(null) }
62
+ })
63
+ })
64
+ req.on('error', () => resolve(null))
65
+ req.on('timeout', () => { req.destroy(); resolve(null) })
66
+ if (body) req.write(bodyStr)
67
+ req.end()
68
+ })
69
+ }
70
+
71
+ function buildKickoffPrompt(task, contextData) {
72
+ const lines = [
73
+ `[ボードタスク自動着手] [task:${task.id}] ${task.title}`,
74
+ '',
75
+ 'このボードタスクが自動でアサインされたため、着手を開始してください。',
76
+ 'アクティブスプリント内でステータスを `doing` にしました。',
77
+ '',
78
+ '完了したら以下で `review` に遷移してユーザーにレビュー依頼してください:',
79
+ ` PATCH \$HQ_URL/api/minion/projects/${task.project_id}/tasks/${task.id}`,
80
+ ' Body: {"status": "review"}',
81
+ '',
82
+ ]
83
+ const injection = buildBoardTaskInjection({
84
+ type: 'board_task',
85
+ task,
86
+ project_id: task.project_id,
87
+ sprint: contextData.sprint || null,
88
+ project_context_content: contextData.projectContextContent || null,
89
+ members: contextData.members || null,
90
+ })
91
+ if (injection) lines.push(injection)
92
+ return lines.join('\n')
93
+ }
94
+
95
+ async function runBoardTask({ task, contextData = {} }) {
96
+ if (!task || !task.id) {
97
+ return { sessionName: null, started: false, success: false, error: 'task is required' }
98
+ }
99
+
100
+ const sessionName = generateSessionName(task.id)
101
+
102
+ // Carry-over check
103
+ const hasRes = await proxyToWsl('GET', `/api/wsl/board-task/has?session=${encodeURIComponent(sessionName)}`)
104
+ if (!hasRes) {
105
+ return { sessionName, started: false, success: false, error: 'WSL session server unreachable (target user must be logged in)' }
106
+ }
107
+ if (hasRes.exists) {
108
+ console.log(`[BoardTaskRunner/Win] tmux session ${sessionName} already exists in WSL, skipping start`)
109
+ runningTasks.add({
110
+ type: 'board_task',
111
+ session_name: sessionName,
112
+ board_task_id: task.id,
113
+ started_at: new Date().toISOString(),
114
+ })
115
+ return { sessionName, started: false, success: true }
116
+ }
117
+
118
+ console.log(`[BoardTaskRunner/Win] Starting board task ${task.id} in WSL`)
119
+ console.log(`[BoardTaskRunner/Win] tmux session: ${sessionName}`)
120
+
121
+ const prompt = buildKickoffPrompt(task, contextData)
122
+ const promptB64 = Buffer.from(prompt, 'utf-8').toString('base64')
123
+
124
+ const startRes = await proxyToWsl('POST', '/api/wsl/board-task/start', {
125
+ session_name: sessionName,
126
+ prompt_b64: promptB64,
127
+ })
128
+ if (!startRes || !startRes.success) {
129
+ const errMsg = (startRes && startRes.error) || 'WSL session server unreachable'
130
+ console.error(`[BoardTaskRunner/Win] start failed: ${errMsg}`)
131
+ return { sessionName, started: false, success: false, error: errMsg }
132
+ }
133
+
134
+ runningTasks.add({
135
+ type: 'board_task',
136
+ session_name: sessionName,
137
+ board_task_id: task.id,
138
+ started_at: new Date().toISOString(),
139
+ })
140
+
141
+ try {
142
+ const startTime = Date.now()
143
+ while (Date.now() - startTime < TASK_TIMEOUT_MS) {
144
+ const exitRes = await proxyToWsl('GET', `/api/wsl/board-task/exit-code?session=${encodeURIComponent(sessionName)}`)
145
+ if (exitRes && exitRes.success && exitRes.exit_code !== null && exitRes.exit_code !== undefined) {
146
+ const exitCode = exitRes.exit_code
147
+ if (exitCode === 0) {
148
+ console.log(`[BoardTaskRunner/Win] Task ${task.id} CLI completed`)
149
+ return { sessionName, started: true, success: true }
150
+ }
151
+ console.error(`[BoardTaskRunner/Win] Task ${task.id} CLI exited with code ${exitCode}`)
152
+ return { sessionName, started: true, success: false, error: `Exit code: ${exitCode}` }
153
+ }
154
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
155
+ }
156
+ console.error(`[BoardTaskRunner/Win] Task ${task.id} timed out after ${TASK_TIMEOUT_MS / 1000}s`)
157
+ await proxyToWsl('POST', '/api/wsl/board-task/kill', { session: sessionName })
158
+ return { sessionName, started: true, success: false, error: 'Execution timeout' }
159
+ } finally {
160
+ runningTasks.remove(sessionName)
161
+ }
162
+ }
163
+
164
+ async function isBoardTaskRunning(taskId) {
165
+ const sessionName = generateSessionName(taskId)
166
+ const res = await proxyToWsl('GET', `/api/wsl/board-task/has?session=${encodeURIComponent(sessionName)}`)
167
+ return !!(res && res.exists)
168
+ }
169
+
170
+ async function listBoardTaskSessions() {
171
+ const res = await proxyToWsl('GET', '/api/wsl/board-tasks')
172
+ if (!res || !res.success) return []
173
+ return (res.sessions || []).map((s) => s.name)
174
+ }
175
+
176
+ module.exports = {
177
+ runBoardTask,
178
+ isBoardTaskRunning,
179
+ generateSessionName,
180
+ listBoardTaskSessions,
181
+ }
@@ -118,14 +118,16 @@ async function chatRoutes(fastify) {
118
118
  return { success: false, error: 'Unauthorized' }
119
119
  }
120
120
 
121
- const { message, session_id, context, wsl_mode, workspace_id } = request.body || {}
121
+ const { message, session_id, context, wsl_mode, workspace_id, referenced_tasks } = request.body || {}
122
122
  if (!message || typeof message !== 'string') {
123
123
  reply.code(400)
124
124
  return { success: false, error: 'message is required' }
125
125
  }
126
126
 
127
127
  const workspaceId = workspace_id || null
128
- const prompt = await buildContextPrefix(message, context, session_id, workspaceId)
128
+ // referenced_tasks is injected into the prompt only (not stored in history)
129
+ // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
130
+ const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
129
131
  const currentSessionId = session_id || null
130
132
 
131
133
  if (currentSessionId) {
@@ -322,9 +324,25 @@ ${indexed}`
322
324
  })
323
325
  }
324
326
 
325
- async function buildContextPrefix(message, context, sessionId, workspaceId) {
327
+ async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks) {
326
328
  const parts = []
327
329
 
330
+ // Resolved [task:UUID] tags from HQ — surface ticket details so Claude can
331
+ // talk about them without first having to hit the API.
332
+ if (Array.isArray(referencedTasks) && referencedTasks.length > 0) {
333
+ parts.push('[参照チケット — ユーザーがメッセージ内で `[task:UUID]` 形式で参照しているHQボード上のタスク]')
334
+ for (const t of referencedTasks) {
335
+ if (!t || !t.id) continue
336
+ const desc = t.description ? ` / ${String(t.description).slice(0, 200).replace(/\s+/g, ' ')}` : ''
337
+ const due = t.due_date ? ` / 期限: ${t.due_date}` : ''
338
+ parts.push(
339
+ `- [task:${t.id}] ${t.title || '(無題)'} (status: ${t.status || '?'}, priority: ${t.priority || '?'}${due})${desc}`,
340
+ ` 詳細/更新: GET|PATCH $HQ_URL/api/minion/projects/${t.project_id}/tasks/${t.id}`,
341
+ )
342
+ }
343
+ parts.push('')
344
+ }
345
+
328
346
  // Inject workspace context so Claude Code knows which workspace it's operating in
329
347
  if (workspaceId) {
330
348
  const workspaceStore = require('../../core/stores/workspace-store')
@@ -474,9 +492,9 @@ async function buildContextPrefix(message, context, sessionId, workspaceId) {
474
492
  ` hq fetch project ${context.projectId}`,
475
493
  ` hq fetch project-context ${context.projectId}`,
476
494
  `タスク・マイルストーン・健康度を扱う場合は以下のAPIを使えます (Bearer 認証必須):`,
477
- ` GET \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 一覧 (?milestone_id= ?status= ?priority=high,urgent ?assignee_minion_id= ?overdue=true ?q=<substring> 等で絞り込み可。q は日本語OKの部分一致)`,
478
- ` POST \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 作成 (body: title, description?, status?, priority?, milestone_id?, parent_task_id?, assignee_minion_id?, due_date?)`,
479
- ` PATCH \$HQ_URL/api/minion/projects/${context.projectId}/tasks/<id> # 更新 (status変更で status_changed_at がサーバ自動更新、priority 変更も可)`,
495
+ ` GET \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 一覧 (?milestone_id= ?sprint_id= ?status= ?priority=high,urgent ?assignee_minion_id= ?overdue=true ?q=<substring> 等で絞り込み可。q は日本語OKの部分一致)`,
496
+ ` POST \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 作成 (body: title, description?, status?, priority?, milestone_id?, sprint_id?, parent_task_id?, assignee_minion_id?, due_date?, acceptance_criteria?)`,
497
+ ` PATCH \$HQ_URL/api/minion/projects/${context.projectId}/tasks/<id> # 更新 (status変更で status_changed_at がサーバ自動更新、acceptance_criteria/sprint_id も更新可。AC更新時は既存idを保持)`,
480
498
  ` GET \$HQ_URL/api/minion/projects/${context.projectId}/milestones # マイルストーン一覧`,
481
499
  ` GET \$HQ_URL/api/minion/projects/${context.projectId}/health # 健康度サマリ (overdue/stalled/マイルストーン進捗。progress_pct は leaf タスク基準)`,
482
500
  `タスクは5段階Kanban (backlog→todo→doing→review→done)、親子は2階層まで(孫不可)。priority は low|normal|high|urgent (可視化+フィルタ用)。親EPICに milestone_id を付ければ子タスクも進捗に自動反映される。詳細は ~/.minion/docs/api-reference.md の「Project Tasks」「Project Milestones」「Project Health」を参照。`,
@@ -181,6 +181,14 @@ async function terminalRoutes(fastify) {
181
181
  }
182
182
  }
183
183
 
184
+ // Merge WSL-tmux board task sessions (bt-*)
185
+ const btResult = await proxyToWsl('GET', '/api/wsl/board-tasks')
186
+ if (btResult && btResult.success && btResult.sessions) {
187
+ for (const s of btResult.sessions) {
188
+ sessions.push({ name: s.name, type: 'wsl-tmux', completed: false })
189
+ }
190
+ }
191
+
184
192
  console.log(`[Terminal] Found ${sessions.length} session(s): ${sessions.map(s => s.name).join(', ') || '(none)'}`)
185
193
  return { success: true, sessions }
186
194
  })
package/win/server.js CHANGED
@@ -28,6 +28,7 @@ let lastBeatAt = null
28
28
  // Windows-specific modules
29
29
  const workflowRunner = require('./workflow-runner')
30
30
  const routineRunner = require('./routine-runner')
31
+ const boardTaskRunner = require('./board-task-runner')
31
32
 
32
33
  // Config warnings (included in heartbeat)
33
34
  const { getConfigWarnings } = require('../core/lib/config-warnings')
@@ -35,6 +36,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
35
36
  // Pull-model daemons (from core/)
36
37
  const stepPoller = require('../core/lib/step-poller')
37
38
  const dagStepPoller = require('../core/lib/dag-step-poller')
39
+ const boardTaskPoller = require('../core/lib/board-task-poller')
38
40
  const revisionWatcher = require('../core/lib/revision-watcher')
39
41
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
40
42
  const threadWatcher = require('../core/lib/thread-watcher')
@@ -97,6 +99,7 @@ async function shutdown(signal) {
97
99
 
98
100
  stepPoller.stop()
99
101
  dagStepPoller.stop()
102
+ boardTaskPoller.stop()
100
103
  revisionWatcher.stop()
101
104
  reflectionScheduler.stop()
102
105
  threadWatcher.stop()
@@ -371,6 +374,8 @@ async function start() {
371
374
  // Start Pull-model daemons
372
375
  stepPoller.start()
373
376
  dagStepPoller.start()
377
+ boardTaskPoller.setRunner(boardTaskRunner)
378
+ boardTaskPoller.start()
374
379
  revisionWatcher.start()
375
380
  threadWatcher.start(runQuickLlmCall)
376
381
  } else {
@@ -17,7 +17,9 @@
17
17
  const fs = require('fs')
18
18
  const path = require('path')
19
19
  const http = require('http')
20
- const { spawn } = require('child_process')
20
+ const { spawn, exec } = require('child_process')
21
+ const { promisify } = require('util')
22
+ const execAsync = promisify(exec)
21
23
 
22
24
  // ---------------------------------------------------------------------------
23
25
  // Configuration
@@ -240,6 +242,43 @@ function streamLlmResponse(res, prompt, sessionId) {
240
242
  })
241
243
  }
242
244
 
245
+ // ---------------------------------------------------------------------------
246
+ // Board Task helpers (run inside WSL via wsl.exe + tmux)
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Run a tmux command inside WSL and return stdout.
251
+ * Throws on non-zero exit.
252
+ */
253
+ async function wslExec(command) {
254
+ const { stdout } = await execAsync(`wsl.exe -e bash -lc ${JSON.stringify(command)}`, {
255
+ encoding: 'utf-8',
256
+ maxBuffer: 4 * 1024 * 1024,
257
+ })
258
+ return stdout
259
+ }
260
+
261
+ async function wslHasTmuxSession(sessionName) {
262
+ try {
263
+ await execAsync(`wsl.exe -e bash -lc ${JSON.stringify(`tmux has-session -t ${sessionName} 2>/dev/null`)}`)
264
+ return true
265
+ } catch {
266
+ return false
267
+ }
268
+ }
269
+
270
+ async function wslListBoardTaskSessions() {
271
+ try {
272
+ const stdout = await wslExec(`tmux ls -F '#S' 2>/dev/null || true`)
273
+ return stdout
274
+ .split('\n')
275
+ .map((s) => s.trim())
276
+ .filter((s) => s.startsWith('bt-'))
277
+ } catch {
278
+ return []
279
+ }
280
+ }
281
+
243
282
  // ---------------------------------------------------------------------------
244
283
  // Fastify HTTP server
245
284
  // ---------------------------------------------------------------------------
@@ -363,6 +402,102 @@ async function startServer() {
363
402
  return { success: true, session: sessionName, content, lines: lineCount, timestamp: Date.now() }
364
403
  })
365
404
 
405
+ // --- Board Task: list bt-* tmux sessions inside WSL ---
406
+ fastify.get('/api/wsl/board-tasks', async (request, reply) => {
407
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
408
+ const names = await wslListBoardTaskSessions()
409
+ return { success: true, sessions: names.map((name) => ({ name, type: 'wsl-tmux' })) }
410
+ })
411
+
412
+ // --- Board Task: check if a bt-* tmux session exists inside WSL ---
413
+ fastify.get('/api/wsl/board-task/has', async (request, reply) => {
414
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
415
+ const { session } = request.query || {}
416
+ if (!session || !/^[\w-]+$/.test(session)) {
417
+ reply.code(400); return { success: false, error: 'Invalid session name' }
418
+ }
419
+ const exists = await wslHasTmuxSession(session)
420
+ return { success: true, exists }
421
+ })
422
+
423
+ // --- Board Task: start a detached tmux session inside WSL ---
424
+ // Request body: { session_name, prompt_b64 }
425
+ // Writes prompt to ~/.minion/board-task-{session_name}.txt inside WSL,
426
+ // then starts: tmux new-session -d -s {name} 'claude -p < prompt; echo $? > exit-file'
427
+ fastify.post('/api/wsl/board-task/start', async (request, reply) => {
428
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
429
+ const { session_name, prompt_b64 } = request.body || {}
430
+ if (!session_name || !/^bt-[\w-]+$/.test(session_name)) {
431
+ reply.code(400); return { success: false, error: 'session_name must match bt-*' }
432
+ }
433
+ if (!prompt_b64 || typeof prompt_b64 !== 'string') {
434
+ reply.code(400); return { success: false, error: 'prompt_b64 is required' }
435
+ }
436
+
437
+ // Skip if already running
438
+ if (await wslHasTmuxSession(session_name)) {
439
+ return { success: true, started: false, message: `Session ${session_name} already exists` }
440
+ }
441
+
442
+ try {
443
+ // Write prompt file inside WSL ($HOME path).
444
+ // Use stdin->base64->file to avoid quoting issues with the prompt content.
445
+ const promptPath = `$HOME/.minion/board-task-${session_name}.txt`
446
+ const exitCodePath = `/tmp/tmux-exit-${session_name}`
447
+ const writeCmd = `mkdir -p $HOME/.minion && echo ${prompt_b64} | base64 -d > ${promptPath}`
448
+ await wslExec(writeCmd)
449
+
450
+ // Build the claude invocation. Default to `claude -p < prompt`.
451
+ // LLM_COMMAND override is ignored here since WSL has its own env;
452
+ // future: read /api/llm/config from the minion-agent if needed.
453
+ const inner = `claude -p < ${promptPath}; echo $? > ${exitCodePath}`
454
+ const tmuxCmd =
455
+ `tmux new-session -d -s ${session_name} -x 200 -y 50 'bash -lc ${JSON.stringify(inner).replace(/'/g, `'\\''`)}'` +
456
+ ` && tmux set-option -t ${session_name} remain-on-exit on`
457
+ await wslExec(tmuxCmd)
458
+
459
+ console.log(`[WSL] Started board task tmux session ${session_name}`)
460
+ return { success: true, started: true, session_name }
461
+ } catch (err) {
462
+ console.error('[WSL] board-task/start failed:', err.message)
463
+ reply.code(500); return { success: false, error: err.message }
464
+ }
465
+ })
466
+
467
+ // --- Board Task: read exit code file (returns null if not yet exited) ---
468
+ fastify.get('/api/wsl/board-task/exit-code', async (request, reply) => {
469
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
470
+ const { session } = request.query || {}
471
+ if (!session || !/^bt-[\w-]+$/.test(session)) {
472
+ reply.code(400); return { success: false, error: 'Invalid session name' }
473
+ }
474
+ try {
475
+ const stdout = await wslExec(`cat /tmp/tmux-exit-${session} 2>/dev/null || echo ""`)
476
+ const trimmed = stdout.trim()
477
+ if (!trimmed) return { success: true, exit_code: null }
478
+ const code = parseInt(trimmed, 10)
479
+ return { success: true, exit_code: isNaN(code) ? null : code }
480
+ } catch (err) {
481
+ reply.code(500); return { success: false, error: err.message }
482
+ }
483
+ })
484
+
485
+ // --- Board Task: kill a bt-* tmux session ---
486
+ fastify.post('/api/wsl/board-task/kill', async (request, reply) => {
487
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
488
+ const { session } = request.body || {}
489
+ if (!session || !/^bt-[\w-]+$/.test(session)) {
490
+ reply.code(400); return { success: false, error: 'Invalid session name' }
491
+ }
492
+ try {
493
+ await wslExec(`tmux kill-session -t ${session} 2>/dev/null || true`)
494
+ await wslExec(`rm -f /tmp/tmux-exit-${session} $HOME/.minion/board-task-${session}.txt 2>/dev/null || true`)
495
+ return { success: true, session }
496
+ } catch (err) {
497
+ reply.code(500); return { success: false, error: err.message }
498
+ }
499
+ })
500
+
366
501
  // --- Chat: SSE stream ---
367
502
  fastify.post('/api/wsl/chat', async (request, reply) => {
368
503
  if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }