@geekbeer/minion 3.42.3 → 3.49.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/core/config.js +3 -1
- package/core/lib/board-task-context.js +87 -0
- package/core/lib/board-task-poller.js +210 -0
- package/core/lib/concurrency-manager.js +56 -0
- package/core/lib/dag-step-poller.js +16 -10
- package/core/lib/platform.js +39 -19
- package/core/routes/daemons.js +2 -0
- package/core/routes/diagnose.js +27 -2
- package/docs/api-reference.md +73 -1
- package/linux/board-task-runner.js +227 -0
- package/linux/routes/chat.js +26 -6
- package/linux/server.js +5 -0
- package/mac/bin/hq +4 -0
- package/mac/board-task-runner.js +4 -0
- package/mac/lib/process-manager.js +109 -0
- package/mac/minion-cli.sh +1353 -0
- package/mac/routes/chat.js +7 -0
- package/mac/routes/commands.js +119 -0
- package/mac/routes/config.js +8 -0
- package/mac/routes/directives.js +6 -0
- package/mac/routes/files.js +6 -0
- package/mac/routes/terminal.js +7 -0
- package/mac/routine-runner.js +4 -0
- package/mac/server.js +413 -0
- package/mac/terminal-proxy.js +6 -0
- package/mac/vnc-auth-proxy.js +402 -0
- package/mac/workflow-runner.js +7 -0
- package/package.json +6 -2
- package/postinstall.js +33 -12
- package/rules/core.md +30 -0
- package/win/board-task-runner.js +181 -0
- package/win/minion-cli.ps1 +123 -5
- package/win/routes/chat.js +24 -6
- package/win/routes/terminal.js +8 -0
- package/win/server.js +5 -0
- package/win/wsl-session-server.js +136 -1
|
@@ -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
|
+
}
|
package/win/minion-cli.ps1
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# minion-cli-win setup # Install software & register services (admin required)
|
|
8
8
|
# minion-cli-win configure --hq-url https://... --minion-id <UUID> --api-token <TOKEN>
|
|
9
9
|
# minion-cli-win uninstall [--keep-data] # Remove agent (admin required)
|
|
10
|
-
# minion-cli-win start | stop | restart | status | health | daemons | diagnose | version | help
|
|
10
|
+
# minion-cli-win start | stop [--force] | restart | status | health | daemons | diagnose | version | help
|
|
11
11
|
|
|
12
12
|
# Parse arguments manually to avoid issues with npm wrapper passing $args as array
|
|
13
13
|
$Command = 'help'
|
|
@@ -16,6 +16,7 @@ $MinionId = ''
|
|
|
16
16
|
$ApiToken = ''
|
|
17
17
|
$SetupTunnel = $false
|
|
18
18
|
$KeepData = $false
|
|
19
|
+
$Force = $false
|
|
19
20
|
|
|
20
21
|
$i = 0
|
|
21
22
|
while ($i -lt $args.Count) {
|
|
@@ -28,6 +29,7 @@ while ($i -lt $args.Count) {
|
|
|
28
29
|
'^--api-token$' { $i++; if ($i -lt $args.Count) { $ApiToken = [string]$args[$i] } }
|
|
29
30
|
'^--setup-tunnel$' { $SetupTunnel = $true }
|
|
30
31
|
'^--keep-data$' { $KeepData = $true }
|
|
32
|
+
'^--force$' { $Force = $true }
|
|
31
33
|
'^(-h|--help)$' { $Command = 'help' }
|
|
32
34
|
}
|
|
33
35
|
$i++
|
|
@@ -40,10 +42,15 @@ $ErrorActionPreference = 'Stop'
|
|
|
40
42
|
# Require Administrator for service management commands
|
|
41
43
|
# ============================================================
|
|
42
44
|
$adminRequired = @('setup', 'uninstall')
|
|
43
|
-
|
|
45
|
+
$needsAdmin = $Command -in $adminRequired
|
|
46
|
+
# `stop --force` rewrites NSSM AppExit config and may need to kill processes
|
|
47
|
+
# owned by the service account → requires Administrator.
|
|
48
|
+
if ($Command -eq 'stop' -and $Force) { $needsAdmin = $true }
|
|
49
|
+
if ($needsAdmin) {
|
|
44
50
|
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
|
45
51
|
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
46
|
-
|
|
52
|
+
$label = if ($Command -eq 'stop' -and $Force) { 'stop --force' } else { $Command }
|
|
53
|
+
Write-Host "ERROR: '$label' requires Administrator privileges." -ForegroundColor Red
|
|
47
54
|
Write-Host " Right-click PowerShell and select 'Run as administrator'." -ForegroundColor Yellow
|
|
48
55
|
exit 1
|
|
49
56
|
}
|
|
@@ -440,6 +447,108 @@ function Stop-MinionService {
|
|
|
440
447
|
Write-Host "minion-agent service stopped"
|
|
441
448
|
}
|
|
442
449
|
|
|
450
|
+
function Stop-MinionServiceForce {
|
|
451
|
+
# Force-stop path used when `stop` (graceful) is broken — typically after a
|
|
452
|
+
# corrupted update where .env is unreadable, the agent API is unresponsive,
|
|
453
|
+
# or NSSM keeps respawning node.exe and locking package files.
|
|
454
|
+
#
|
|
455
|
+
# Steps:
|
|
456
|
+
# 1. Disable NSSM auto-restart on every minion service (so sc.exe stop sticks)
|
|
457
|
+
# 2. sc.exe stop each service
|
|
458
|
+
# 3. Kill remaining helper processes (tvnserver/websockify/cloudflared)
|
|
459
|
+
# 4. Kill any node.exe whose CommandLine references a minion script
|
|
460
|
+
# 5. Restore NSSM AppExit Restart so subsequent `start` behaves normally
|
|
461
|
+
Write-Host ""
|
|
462
|
+
Write-Host "=========================================" -ForegroundColor Yellow
|
|
463
|
+
Write-Host " Stop --force: bypassing graceful shutdown" -ForegroundColor Yellow
|
|
464
|
+
Write-Host "=========================================" -ForegroundColor Yellow
|
|
465
|
+
Write-Host ""
|
|
466
|
+
|
|
467
|
+
$services = @('minion-agent', 'minion-websockify', 'minion-cloudflared', 'minion-vnc')
|
|
468
|
+
$nssmAvailable = $NssmPath -and (Test-Path $NssmPath)
|
|
469
|
+
|
|
470
|
+
# Step 1: Disable NSSM auto-restart so sc.exe stop is not undone.
|
|
471
|
+
if ($nssmAvailable) {
|
|
472
|
+
foreach ($svc in $services) {
|
|
473
|
+
$state = Get-ServiceState $svc
|
|
474
|
+
if (-not $state) { continue }
|
|
475
|
+
Invoke-Nssm set $svc AppExit Default Exit | Out-Null
|
|
476
|
+
Invoke-Nssm set $svc AppThrottle 0 | Out-Null
|
|
477
|
+
Write-Detail "Disabled auto-restart for $svc"
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
Write-Warn "NSSM not found — skipping AppExit override (services may auto-restart)"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Step 2: Stop each service via sc.exe (works without admin if SDDL granted).
|
|
484
|
+
foreach ($svc in $services) {
|
|
485
|
+
$state = Get-ServiceState $svc
|
|
486
|
+
if (-not $state) { continue }
|
|
487
|
+
if ($state -eq 'STOPPED') {
|
|
488
|
+
Write-Detail "$svc already stopped"
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
sc.exe stop $svc 2>&1 | Out-Null
|
|
492
|
+
for ($i = 0; $i -lt 10; $i++) {
|
|
493
|
+
$s = Get-ServiceState $svc
|
|
494
|
+
if ($s -eq 'STOPPED') { break }
|
|
495
|
+
Start-Sleep -Seconds 1
|
|
496
|
+
}
|
|
497
|
+
$final = Get-ServiceState $svc
|
|
498
|
+
if ($final -eq 'STOPPED') {
|
|
499
|
+
Write-Detail "$svc stopped"
|
|
500
|
+
} else {
|
|
501
|
+
Write-Warn "$svc state=$final (sc.exe stop did not complete in 10s)"
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# Step 3: Kill helper processes that may have been spawned outside NSSM.
|
|
506
|
+
foreach ($name in @('tvnserver', 'websockify', 'cloudflared')) {
|
|
507
|
+
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
|
|
508
|
+
if ($procs) {
|
|
509
|
+
$procs | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
510
|
+
Write-Detail "Killed $name ($($procs.Count) process(es))"
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
# Step 4: Kill node.exe processes that hold the package files. We match on
|
|
515
|
+
# CommandLine so we don't touch unrelated node processes the user is running.
|
|
516
|
+
$nodePattern = 'minion|@geekbeer\\minion|terminal-server|workflow-runner|routine-runner|wsl-session-server'
|
|
517
|
+
try {
|
|
518
|
+
$nodeProcs = Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction Stop |
|
|
519
|
+
Where-Object { $_.CommandLine -match $nodePattern }
|
|
520
|
+
foreach ($p in $nodeProcs) {
|
|
521
|
+
try {
|
|
522
|
+
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
|
|
523
|
+
Write-Detail "Killed node.exe pid=$($p.ProcessId)"
|
|
524
|
+
} catch {
|
|
525
|
+
Write-Warn "Failed to kill node.exe pid=$($p.ProcessId): $($_.Exception.Message)"
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (-not $nodeProcs) {
|
|
529
|
+
Write-Detail "No matching node.exe processes"
|
|
530
|
+
}
|
|
531
|
+
} catch {
|
|
532
|
+
Write-Warn "Could not enumerate node processes: $($_.Exception.Message)"
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
# Step 5: Restore AppExit Restart so a subsequent `start` behaves normally.
|
|
536
|
+
if ($nssmAvailable) {
|
|
537
|
+
foreach ($svc in $services) {
|
|
538
|
+
$state = Get-ServiceState $svc
|
|
539
|
+
if (-not $state) { continue }
|
|
540
|
+
Invoke-Nssm set $svc AppExit Default Restart | Out-Null
|
|
541
|
+
Invoke-Nssm set $svc AppThrottle 1500 | Out-Null
|
|
542
|
+
}
|
|
543
|
+
Write-Detail "Restored NSSM auto-restart settings"
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
Write-Host ""
|
|
547
|
+
Write-Host "Force-stop complete. If files are still locked, run:" -ForegroundColor Cyan
|
|
548
|
+
Write-Host " Get-Process | Where-Object { `$_.Modules.FileName -like '*\@geekbeer\minion\*' } | Select Id,ProcessName" -ForegroundColor Gray
|
|
549
|
+
Write-Host ""
|
|
550
|
+
}
|
|
551
|
+
|
|
443
552
|
function Restart-MinionService {
|
|
444
553
|
Stop-MinionService
|
|
445
554
|
Start-Sleep -Seconds 2
|
|
@@ -1673,7 +1782,9 @@ switch ($Command) {
|
|
|
1673
1782
|
'reconfigure' { Invoke-Configure } # alias for backwards compatibility
|
|
1674
1783
|
'uninstall' { Invoke-Uninstall }
|
|
1675
1784
|
'start' { Start-MinionService }
|
|
1676
|
-
'stop' {
|
|
1785
|
+
'stop' {
|
|
1786
|
+
if ($Force) { Stop-MinionServiceForce } else { Stop-MinionService }
|
|
1787
|
+
}
|
|
1677
1788
|
'restart' { Restart-MinionService }
|
|
1678
1789
|
'status' { Show-Status }
|
|
1679
1790
|
'health' { Show-Health }
|
|
@@ -1692,7 +1803,8 @@ switch ($Command) {
|
|
|
1692
1803
|
Write-Host "Commands (no admin required):"
|
|
1693
1804
|
Write-Host " configure Connect to HQ, deploy skills, start services"
|
|
1694
1805
|
Write-Host " start Start the minion-agent service"
|
|
1695
|
-
Write-Host " stop Stop the minion-agent service"
|
|
1806
|
+
Write-Host " stop Stop the minion-agent service (graceful)"
|
|
1807
|
+
Write-Host " stop --force Force-stop all minion services & processes (admin required)"
|
|
1696
1808
|
Write-Host " restart Restart the minion-agent service"
|
|
1697
1809
|
Write-Host " status Show agent service status"
|
|
1698
1810
|
Write-Host " health Check agent health endpoint"
|
|
@@ -1709,5 +1821,11 @@ switch ($Command) {
|
|
|
1709
1821
|
Write-Host ""
|
|
1710
1822
|
Write-Host "Uninstall options:"
|
|
1711
1823
|
Write-Host " --keep-data Preserve .env file"
|
|
1824
|
+
Write-Host ""
|
|
1825
|
+
Write-Host "Stop options:"
|
|
1826
|
+
Write-Host " --force Disable NSSM auto-restart, sc.exe stop all services,"
|
|
1827
|
+
Write-Host " kill remaining helpers (tvnserver/websockify/cloudflared)"
|
|
1828
|
+
Write-Host " and node.exe processes that lock package files."
|
|
1829
|
+
Write-Host " Use when graceful stop fails (e.g. corrupted update)."
|
|
1712
1830
|
}
|
|
1713
1831
|
}
|
package/win/routes/chat.js
CHANGED
|
@@ -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
|
-
|
|
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 がサーバ自動更新、
|
|
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」を参照。`,
|
package/win/routes/terminal.js
CHANGED
|
@@ -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' } }
|