@geekbeer/minion 3.10.0 → 3.11.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.
@@ -162,6 +162,86 @@ URL ベースの MCP サーバーは `url` フィールドで指定する(`com
162
162
 
163
163
  ---
164
164
 
165
+ ## WSL セッション(Windows ミニオン限定)
166
+
167
+ Windows ミニオンで WSL(Windows Subsystem for Linux)内の Docker やリポジトリ操作を行うための機能。
168
+
169
+ ### 概要
170
+
171
+ minion-agent は LocalSystem (Session 0) で動作するため、ユーザー単位の WSL ディストリビューションに直接アクセスできない。WSL セッションサーバーがユーザーセッションで別プロセスとして動作し、minion-agent がリクエストをプロキシする。
172
+
173
+ ### 前提条件
174
+
175
+ - WSL 2 がインストール済み(`wsl --install` で導入可能)
176
+ - Linux ディストリビューション(Ubuntu 等)がセットアップ済み
177
+ - Docker Desktop for Windows がインストール済み(WSL 2 バックエンド有効)
178
+ - ターゲットユーザーがログイン中(schtasks ONLOGON でサーバーが起動するため)
179
+
180
+ ### セットアップ
181
+
182
+ `minion-cli setup` を実行すると、WSL が検出された場合に自動で登録される。手動で確認・起動する場合:
183
+
184
+ ```powershell
185
+ # WSL セッションサーバーの状態確認
186
+ schtasks /Query /TN "MinionWSL" /FO LIST
187
+
188
+ # 手動起動(テスト用)
189
+ schtasks /Run /TN "MinionWSL"
190
+
191
+ # ヘルスチェック
192
+ curl http://localhost:7682/api/wsl/health
193
+ ```
194
+
195
+ ### 使い方
196
+
197
+ #### ターミナルセッション
198
+
199
+ ```bash
200
+ # WSL ターミナルセッション作成
201
+ curl -X POST http://localhost:8080/api/terminal/create \
202
+ -H "Authorization: Bearer $API_TOKEN" \
203
+ -H "Content-Type: application/json" \
204
+ -d '{"name": "wsl-dev", "type": "wsl"}'
205
+
206
+ # コマンド送信
207
+ curl -X POST http://localhost:8080/api/terminal/send \
208
+ -H "Authorization: Bearer $API_TOKEN" \
209
+ -H "Content-Type: application/json" \
210
+ -d '{"session": "wsl-dev", "input": "docker compose up -d", "enter": true}'
211
+
212
+ # WSL セッションサーバーの稼働状態確認
213
+ curl http://localhost:8080/api/terminal/wsl/status \
214
+ -H "Authorization: Bearer $API_TOKEN"
215
+ ```
216
+
217
+ #### チャット(WSL モード)
218
+
219
+ チャット API に `wsl_mode: true` を追加すると、Claude Code CLI がユーザーセッションで実行され、`wsl` コマンドを直接使用できる。
220
+
221
+ ```bash
222
+ curl -X POST http://localhost:8080/api/chat \
223
+ -H "Authorization: Bearer $API_TOKEN" \
224
+ -H "Content-Type: application/json" \
225
+ -d '{"message": "WSL内でリポジトリをクローンしてDockerを起動して", "wsl_mode": true}'
226
+ ```
227
+
228
+ ### ポート構成
229
+
230
+ | ポート | サービス | 備考 |
231
+ |--------|---------|------|
232
+ | 7682 | WSL session server (HTTP API) | localhost のみ |
233
+ | 7683 | WSL session server (WebSocket) | localhost のみ、ttyd プロトコル |
234
+
235
+ ### トラブルシューティング
236
+
237
+ | 問題 | 原因 | 対処 |
238
+ |------|------|------|
239
+ | WSL session server is not running | ユーザーが未ログイン | RDP/コンソールでログイン |
240
+ | WSL not detected during setup | WSL 未インストール | `wsl --install` を実行後 `minion-cli setup` を再実行 |
241
+ | Connection refused on port 7682 | サーバー異常終了 | `schtasks /Run /TN "MinionWSL"` で再起動 |
242
+
243
+ ---
244
+
165
245
  ## パッケージのインストール
166
246
 
167
247
  スキルが `requires.packages` で宣言したパッケージは、対応するパッケージマネージャーの list コマンドで検出される。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WSL Session Server Entry Point
4
+ *
5
+ * Designed to be launched via schtasks ONLOGON so it runs in the
6
+ * target user's interactive session (not LocalSystem/Session 0).
7
+ *
8
+ * Reads configuration from .minion/.env, sets up environment,
9
+ * then starts the WSL session server.
10
+ */
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Resolve HOME_DIR from .minion/.env or environment
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function loadEnvFile(envPath) {
20
+ if (!fs.existsSync(envPath)) return {}
21
+ const env = {}
22
+ for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
23
+ const trimmed = line.trim()
24
+ if (!trimmed || trimmed.startsWith('#')) continue
25
+ const eqIdx = trimmed.indexOf('=')
26
+ if (eqIdx < 0) continue
27
+ const key = trimmed.slice(0, eqIdx).trim()
28
+ let value = trimmed.slice(eqIdx + 1).trim()
29
+ // Strip surrounding quotes
30
+ if ((value.startsWith('"') && value.endsWith('"')) ||
31
+ (value.startsWith("'") && value.endsWith("'"))) {
32
+ value = value.slice(1, -1)
33
+ }
34
+ env[key] = value
35
+ }
36
+ return env
37
+ }
38
+
39
+ // Determine HOME_DIR: prefer .minion/.env, fallback to env vars
40
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ''
41
+ const dataDir = path.join(homeDir, '.minion')
42
+ const envFile = path.join(dataDir, '.env')
43
+ const envVars = loadEnvFile(envFile)
44
+
45
+ // Apply env vars that aren't already set
46
+ for (const [key, value] of Object.entries(envVars)) {
47
+ if (!(key in process.env)) process.env[key] = value
48
+ }
49
+
50
+ // Ensure HOME/USERPROFILE are set
51
+ if (!process.env.HOME) process.env.HOME = homeDir
52
+ if (!process.env.USERPROFILE) process.env.USERPROFILE = homeDir
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Extend PATH (same logic as server.js)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ try {
59
+ const { buildExtendedPath } = require('../../core/lib/platform')
60
+ process.env.PATH = buildExtendedPath(homeDir)
61
+ } catch (err) {
62
+ console.warn('[WSL-Entry] Could not extend PATH:', err.message)
63
+ }
64
+
65
+ // Load minion secrets into process.env
66
+ try {
67
+ const variableStore = require('../../core/stores/variable-store')
68
+ const minionEnv = variableStore.buildEnv()
69
+ for (const [key, value] of Object.entries(minionEnv)) {
70
+ if (!(key in process.env)) process.env[key] = value
71
+ }
72
+ console.log(`[WSL-Entry] Loaded ${Object.keys(minionEnv).length} minion secrets`)
73
+ } catch (err) {
74
+ console.warn('[WSL-Entry] Could not load minion secrets:', err.message)
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Start the server
79
+ // ---------------------------------------------------------------------------
80
+
81
+ console.log(`[WSL-Entry] HOME_DIR: ${homeDir}`)
82
+ console.log(`[WSL-Entry] PID: ${process.pid}`)
83
+
84
+ const { startServer } = require('../wsl-session-server')
85
+
86
+ startServer().catch((err) => {
87
+ console.error('[WSL-Entry] Failed to start WSL session server:', err)
88
+ process.exit(1)
89
+ })
@@ -800,6 +800,35 @@ function Invoke-Setup {
800
800
  Write-Detail "TightVNC registered as logon task (user session, not service)"
801
801
  }
802
802
 
803
+ # Step 9b: Register WSL session server (runs in user session for WSL access)
804
+ $wslServerJs = Join-Path $minionPkgDir 'win\bin\wsl-session-entry.js'
805
+ if (Test-Path $wslServerJs) {
806
+ # Generate shared auth token for minion-agent <-> wsl-session-server communication
807
+ $wslTokenFile = Join-Path $DataDir '.wsl-session-token'
808
+ if (-not (Test-Path $wslTokenFile)) {
809
+ $wslToken = [System.Guid]::NewGuid().ToString('N')
810
+ [System.IO.File]::WriteAllText($wslTokenFile, $wslToken)
811
+ Write-Detail "WSL session token generated"
812
+ } else {
813
+ Write-Detail "WSL session token already exists"
814
+ }
815
+
816
+ $wslAvailable = $false
817
+ if (Get-Command wsl.exe -ErrorAction SilentlyContinue) {
818
+ $wslAvailable = $true
819
+ }
820
+
821
+ if ($wslAvailable) {
822
+ Remove-ScheduledTaskSilent "MinionWSL"
823
+ $wslTR = "'$nodePath' '$wslServerJs'"
824
+ schtasks /Create /TN "MinionWSL" /TR $wslTR /SC ONLOGON /RL HIGHEST /F | Out-Null
825
+ Write-Detail "WSL session server registered as logon task (user session)"
826
+ } else {
827
+ Write-Warn "WSL not detected. WSL session server not registered."
828
+ Write-Detail "Install WSL and re-run setup to enable WSL sessions."
829
+ }
830
+ }
831
+
803
832
  # Step 10: Setup websockify (runs as LocalSystem, paired with VNC)
804
833
  Write-Step 9 $totalSteps "Setting up websockify..."
805
834
  [array]$wsCmd = Get-WebsockifyCommand
@@ -949,6 +978,7 @@ function Invoke-Setup {
949
978
  $fwRules = @(
950
979
  @{ Name = 'Minion Agent'; Port = 8080 },
951
980
  @{ Name = 'Minion Terminal'; Port = 7681 },
981
+ @{ Name = 'Minion WSL Terminal'; Port = 7683 },
952
982
  @{ Name = 'Minion VNC'; Port = 6080 }
953
983
  )
954
984
  foreach ($rule in $fwRules) {
@@ -1096,6 +1126,10 @@ function Invoke-Uninstall {
1096
1126
  }
1097
1127
  }
1098
1128
 
1129
+ # Remove WSL session server logon task
1130
+ Remove-ScheduledTaskSilent "MinionWSL"
1131
+ Write-Detail "WSL session server logon task removed"
1132
+
1099
1133
  # Remove VNC logon task and legacy NSSM service
1100
1134
  Remove-ScheduledTaskSilent "MinionVNC"
1101
1135
  Invoke-Nssm stop minion-vnc
@@ -15,6 +15,7 @@
15
15
  const { spawn } = require('child_process')
16
16
  const fs = require('fs')
17
17
  const path = require('path')
18
+ const http = require('http')
18
19
  const { verifyToken } = require('../../core/lib/auth')
19
20
  const { config } = require('../../core/config')
20
21
  const chatStore = require('../../core/stores/chat-store')
@@ -22,6 +23,91 @@ const { DATA_DIR } = require('../../core/lib/platform')
22
23
  const { runEndOfDay } = require('../../core/lib/end-of-day')
23
24
 
24
25
  let activeChatChild = null
26
+ let wslModeActive = false
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // WSL session server proxy helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
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
+ /**
42
+ * Proxy a chat request to the WSL session server's /api/wsl/chat endpoint.
43
+ * Pipes the SSE stream from the WSL server back to the client.
44
+ */
45
+ function proxyWslChat(res, prompt, sessionId) {
46
+ return new Promise((resolve, reject) => {
47
+ const token = getWslToken()
48
+ const body = JSON.stringify({ prompt, session_id: sessionId })
49
+ const req = http.request({
50
+ hostname: '127.0.0.1',
51
+ port: WSL_PORT,
52
+ path: '/api/wsl/chat',
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ 'Authorization': `Bearer ${token}`,
57
+ 'Content-Length': Buffer.byteLength(body),
58
+ },
59
+ }, (upstream) => {
60
+ if (upstream.statusCode !== 200) {
61
+ const errorEvent = JSON.stringify({ type: 'error', error: `WSL session server returned ${upstream.statusCode}` })
62
+ res.write(`data: ${errorEvent}\n\n`)
63
+ resolve()
64
+ return
65
+ }
66
+ upstream.on('data', (chunk) => { res.write(chunk) })
67
+ upstream.on('end', () => { resolve() })
68
+ upstream.on('error', (err) => {
69
+ res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`)
70
+ resolve()
71
+ })
72
+ })
73
+ req.on('error', (err) => {
74
+ const errorMsg = err.code === 'ECONNREFUSED'
75
+ ? 'WSL session server is not running. The target user must be logged in for WSL sessions.'
76
+ : `WSL proxy error: ${err.message}`
77
+ res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
78
+ resolve()
79
+ })
80
+ req.write(body)
81
+ req.end()
82
+
83
+ // If client disconnects, abort upstream request
84
+ res.on('close', () => { req.destroy() })
85
+ })
86
+ }
87
+
88
+ function proxyWslChatAbort() {
89
+ return new Promise((resolve) => {
90
+ const token = getWslToken()
91
+ const req = http.request({
92
+ hostname: '127.0.0.1',
93
+ port: WSL_PORT,
94
+ path: '/api/wsl/chat/abort',
95
+ method: 'POST',
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'Authorization': `Bearer ${token}`,
99
+ },
100
+ }, (res) => {
101
+ let body = ''
102
+ res.on('data', (c) => { body += c })
103
+ res.on('end', () => {
104
+ try { resolve(JSON.parse(body)) } catch { resolve({ success: false }) }
105
+ })
106
+ })
107
+ req.on('error', () => { resolve({ success: false, error: 'WSL session server unreachable' }) })
108
+ req.end('{}')
109
+ })
110
+ }
25
111
 
26
112
  async function chatRoutes(fastify) {
27
113
  fastify.post('/api/chat', async (request, reply) => {
@@ -30,7 +116,7 @@ async function chatRoutes(fastify) {
30
116
  return { success: false, error: 'Unauthorized' }
31
117
  }
32
118
 
33
- const { message, session_id, context } = request.body || {}
119
+ const { message, session_id, context, wsl_mode } = request.body || {}
34
120
  if (!message || typeof message !== 'string') {
35
121
  reply.code(400)
36
122
  return { success: false, error: 'message is required' }
@@ -52,8 +138,16 @@ async function chatRoutes(fastify) {
52
138
  reply.raw.flushHeaders()
53
139
 
54
140
  try {
55
- await streamLlmResponse(reply.raw, prompt, currentSessionId)
141
+ if (wsl_mode) {
142
+ wslModeActive = true
143
+ console.log('[Chat] WSL mode enabled — proxying to WSL session server')
144
+ await proxyWslChat(reply.raw, prompt, currentSessionId)
145
+ wslModeActive = false
146
+ } else {
147
+ await streamLlmResponse(reply.raw, prompt, currentSessionId)
148
+ }
56
149
  } catch (err) {
150
+ wslModeActive = false
57
151
  console.error('[Chat] stream error:', err.message)
58
152
  const errorEvent = JSON.stringify({ type: 'error', error: err.message })
59
153
  reply.raw.write(`data: ${errorEvent}\n\n`)
@@ -122,6 +216,13 @@ async function chatRoutes(fastify) {
122
216
  reply.code(401)
123
217
  return { success: false, error: 'Unauthorized' }
124
218
  }
219
+
220
+ // If WSL mode chat is active, proxy abort to WSL session server
221
+ if (wslModeActive) {
222
+ console.log('[Chat] Aborting WSL mode chat — proxying to WSL session server')
223
+ return proxyWslChatAbort()
224
+ }
225
+
125
226
  if (!activeChatChild) {
126
227
  return { success: false, error: 'No active chat process' }
127
228
  }
@@ -8,11 +8,65 @@
8
8
  */
9
9
 
10
10
  const path = require('path')
11
+ const fs = require('fs')
12
+ const http = require('http')
11
13
  const { verifyToken } = require('../../core/lib/auth')
12
14
  const { config } = require('../../core/config')
13
15
  const { activeSessions } = require('../workflow-runner')
14
16
 
15
17
  const homeDir = config.HOME_DIR
18
+ const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // WSL session server proxy helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function getWslToken() {
25
+ try {
26
+ const tokenPath = path.join(config.HOME_DIR, '.minion', '.wsl-session-token')
27
+ return fs.readFileSync(tokenPath, 'utf-8').trim()
28
+ } catch { return '' }
29
+ }
30
+
31
+ /**
32
+ * Proxy an HTTP request to the WSL session server.
33
+ * Returns parsed JSON response or null on failure.
34
+ */
35
+ function proxyToWsl(method, urlPath, body) {
36
+ return new Promise((resolve) => {
37
+ const token = getWslToken()
38
+ const bodyStr = body ? JSON.stringify(body) : ''
39
+ const headers = {
40
+ 'Authorization': `Bearer ${token}`,
41
+ }
42
+ if (body) {
43
+ headers['Content-Type'] = 'application/json'
44
+ headers['Content-Length'] = Buffer.byteLength(bodyStr)
45
+ }
46
+
47
+ const req = http.request({
48
+ hostname: '127.0.0.1',
49
+ port: WSL_PORT,
50
+ path: urlPath,
51
+ method,
52
+ headers,
53
+ }, (res) => {
54
+ let data = ''
55
+ res.on('data', (c) => { data += c })
56
+ res.on('end', () => {
57
+ try { resolve(JSON.parse(data)) } catch { resolve(null) }
58
+ })
59
+ })
60
+ req.on('error', () => { resolve(null) })
61
+ if (body) req.write(bodyStr)
62
+ req.end()
63
+ })
64
+ }
65
+
66
+ /** Check if a session name belongs to WSL (wsl- prefix). */
67
+ function isWslSession(name) {
68
+ return name && name.startsWith('wsl-')
69
+ }
16
70
 
17
71
  /**
18
72
  * Load node-pty dynamically.
@@ -100,7 +154,7 @@ function createPtySession(sessionName, command) {
100
154
  * @param {import('fastify').FastifyInstance} fastify
101
155
  */
102
156
  async function terminalRoutes(fastify) {
103
- // List sessions
157
+ // List sessions (merges local CMD sessions + WSL sessions from wsl-session-server)
104
158
  fastify.get('/api/terminal/sessions', async (request, reply) => {
105
159
  if (!verifyToken(request)) {
106
160
  reply.code(401)
@@ -111,6 +165,7 @@ async function terminalRoutes(fastify) {
111
165
  for (const [name, session] of activeSessions) {
112
166
  sessions.push({
113
167
  name,
168
+ type: 'cmd',
114
169
  attached: false,
115
170
  completed: session.completed,
116
171
  exit_code: session.exitCode,
@@ -118,6 +173,14 @@ async function terminalRoutes(fastify) {
118
173
  })
119
174
  }
120
175
 
176
+ // Merge WSL sessions (best-effort, skip on failure)
177
+ const wslResult = await proxyToWsl('GET', '/api/wsl/sessions')
178
+ if (wslResult && wslResult.success && wslResult.sessions) {
179
+ for (const s of wslResult.sessions) {
180
+ sessions.push({ ...s, type: 'wsl' })
181
+ }
182
+ }
183
+
121
184
  console.log(`[Terminal] Found ${sessions.length} session(s): ${sessions.map(s => s.name).join(', ') || '(none)'}`)
122
185
  return { success: true, sessions }
123
186
  })
@@ -139,7 +202,15 @@ async function terminalRoutes(fastify) {
139
202
  return { success: false, error: 'Invalid session name' }
140
203
  }
141
204
 
205
+ // Proxy to WSL server if session is a WSL session not found locally
142
206
  const session = activeSessions.get(sessionName)
207
+ if (!session && isWslSession(sessionName)) {
208
+ const result = await proxyToWsl('GET', `/api/wsl/capture?session=${encodeURIComponent(sessionName)}&lines=${lines}`)
209
+ if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
210
+ reply.code(result.success ? 200 : 404)
211
+ return result
212
+ }
213
+
143
214
  if (!session) {
144
215
  reply.code(404)
145
216
  return { success: false, error: `Session '${sessionName}' not found` }
@@ -179,7 +250,15 @@ async function terminalRoutes(fastify) {
179
250
  return { success: false, error: 'input or special key is required' }
180
251
  }
181
252
 
253
+ // Proxy to WSL server if session is a WSL session not found locally
182
254
  const session = activeSessions.get(sessionName)
255
+ if (!session && isWslSession(sessionName)) {
256
+ const result = await proxyToWsl('POST', '/api/wsl/send', { session: sessionName, input, enter, special })
257
+ if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
258
+ reply.code(result.success ? 200 : 400)
259
+ return result
260
+ }
261
+
183
262
  if (!session || !session.pty) {
184
263
  reply.code(404)
185
264
  return { success: false, error: `Session '${sessionName}' not found` }
@@ -224,14 +303,29 @@ async function terminalRoutes(fastify) {
224
303
  }
225
304
  })
226
305
 
227
- // Create a new session
306
+ // Create a new session (supports type: 'wsl' for WSL sessions)
228
307
  fastify.post('/api/terminal/create', async (request, reply) => {
229
308
  if (!verifyToken(request)) {
230
309
  reply.code(401)
231
310
  return { success: false, error: 'Unauthorized' }
232
311
  }
233
312
 
234
- const { name, command } = request.body || {}
313
+ const { name, command, type } = request.body || {}
314
+
315
+ // WSL session: proxy to wsl-session-server
316
+ if (type === 'wsl') {
317
+ const sessionName = name || `wsl-session-${Date.now()}`
318
+ const wslName = sessionName.startsWith('wsl-') ? sessionName : `wsl-${sessionName}`
319
+ console.log(`[Terminal] Creating WSL session '${wslName}' — proxying to WSL server`)
320
+ const result = await proxyToWsl('POST', '/api/wsl/create', { name: wslName, command })
321
+ if (!result) {
322
+ reply.code(503)
323
+ return { success: false, error: 'WSL session server is not running. The target user must be logged in for WSL sessions.' }
324
+ }
325
+ reply.code(result.success ? 200 : 500)
326
+ return result
327
+ }
328
+
235
329
  const sessionName = name || `session-${Date.now()}`
236
330
 
237
331
  if (!/^[\w-]+$/.test(sessionName)) {
@@ -272,7 +366,15 @@ async function terminalRoutes(fastify) {
272
366
  return { success: false, error: 'Invalid session name' }
273
367
  }
274
368
 
369
+ // Proxy to WSL server if session is a WSL session not found locally
275
370
  const session = activeSessions.get(sessionName)
371
+ if (!session && isWslSession(sessionName)) {
372
+ const result = await proxyToWsl('POST', '/api/wsl/kill', { session: sessionName })
373
+ if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
374
+ reply.code(result.success ? 200 : 404)
375
+ return result
376
+ }
377
+
276
378
  if (!session) {
277
379
  reply.code(404)
278
380
  return { success: false, error: `Session '${sessionName}' not found` }
@@ -315,6 +417,22 @@ async function terminalRoutes(fastify) {
315
417
  active_sessions: sessions,
316
418
  }
317
419
  })
420
+
421
+ // WSL session server status
422
+ fastify.get('/api/terminal/wsl/status', async (request, reply) => {
423
+ if (!verifyToken(request)) {
424
+ reply.code(401)
425
+ return { success: false, error: 'Unauthorized' }
426
+ }
427
+
428
+ const health = await proxyToWsl('GET', '/api/wsl/health')
429
+ return {
430
+ success: true,
431
+ available: !!getWslToken(), // token exists = WSL was configured during setup
432
+ running: !!(health && health.success),
433
+ server_pid: health?.pid || null,
434
+ }
435
+ })
318
436
  }
319
437
 
320
438
  function cleanupSessions() {
package/win/server.js CHANGED
@@ -284,6 +284,28 @@ async function start() {
284
284
  console.error('[Server] Terminal WebSocket connections will not work')
285
285
  }
286
286
 
287
+ // Check WSL session server availability (best-effort)
288
+ try {
289
+ const http = require('http')
290
+ const wslPort = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
291
+ const wslCheck = await new Promise((resolve) => {
292
+ const req = http.get(`http://127.0.0.1:${wslPort}/api/wsl/health`, { timeout: 2000 }, (res) => {
293
+ let body = ''
294
+ res.on('data', (c) => { body += c })
295
+ res.on('end', () => { try { resolve(JSON.parse(body)) } catch { resolve(null) } })
296
+ })
297
+ req.on('error', () => resolve(null))
298
+ req.on('timeout', () => { req.destroy(); resolve(null) })
299
+ })
300
+ if (wslCheck && wslCheck.success) {
301
+ console.log(`[Server] WSL session server is running (PID: ${wslCheck.pid}, port: ${wslPort})`)
302
+ } else {
303
+ console.log('[Server] WSL session server is not running (user may not be logged in)')
304
+ }
305
+ } catch {
306
+ console.log('[Server] WSL session server check skipped')
307
+ }
308
+
287
309
  // Load cached workflows
288
310
  try {
289
311
  const cachedWorkflows = await workflowStore.load()
@@ -12,16 +12,24 @@
12
12
  * Listens on port 7681, URL: /ws/{sessionName}
13
13
  */
14
14
 
15
+ const fs = require('fs')
15
16
  const http = require('http')
16
17
  const path = require('path')
17
18
  const { config } = require('../core/config')
18
19
  const { activeSessions } = require('./workflow-runner')
19
20
 
20
21
  const PROXY_PORT = 7681
22
+ const WSL_WS_PORT = (parseInt(process.env.WSL_SESSION_PORT, 10) || 7682) + 1 // 7683
21
23
 
22
24
  let wss = null
23
25
  let httpServer = null
24
26
 
27
+ function getWslToken() {
28
+ try {
29
+ return fs.readFileSync(path.join(config.HOME_DIR, '.minion', '.wsl-session-token'), 'utf-8').trim()
30
+ } catch { return '' }
31
+ }
32
+
25
33
  // ttyd message types (ASCII character codes, matching ttyd wire protocol)
26
34
  const MSG_INPUT = 0x30 // '0'
27
35
  const MSG_RESIZE = 0x31 // '1'
@@ -151,6 +159,12 @@ function startTerminalServer() {
151
159
  return
152
160
  }
153
161
 
162
+ // WSL sessions: proxy to wsl-session-server's WebSocket
163
+ if (sessionName.startsWith('wsl-')) {
164
+ proxyToWslWebSocket(ws, sessionName, WebSocket)
165
+ return
166
+ }
167
+
154
168
  let session
155
169
  try {
156
170
  session = ensureSession(sessionName)
@@ -225,6 +239,54 @@ function startTerminalServer() {
225
239
  })
226
240
  }
227
241
 
242
+ /**
243
+ * Proxy a WebSocket connection to the WSL session server.
244
+ * Bidirectional pipe: HQ client <-> minion-agent:7681 <-> wsl-session-server:7683
245
+ */
246
+ function proxyToWslWebSocket(clientWs, sessionName, WebSocket) {
247
+ const token = getWslToken()
248
+ const upstreamUrl = `ws://127.0.0.1:${WSL_WS_PORT}/ws/${sessionName}?token=${encodeURIComponent(token)}`
249
+
250
+ console.log(`[TerminalServer] Proxying WSL session '${sessionName}' to ${upstreamUrl}`)
251
+
252
+ const upstream = new WebSocket(upstreamUrl)
253
+
254
+ upstream.on('open', () => {
255
+ console.log(`[TerminalServer] WSL proxy connected for '${sessionName}'`)
256
+ })
257
+
258
+ // Forward data from WSL server to HQ client
259
+ upstream.on('message', (data) => {
260
+ try { if (clientWs.readyState === 1) clientWs.send(data) } catch {}
261
+ })
262
+
263
+ // Forward data from HQ client to WSL server
264
+ clientWs.on('message', (data) => {
265
+ try { if (upstream.readyState === 1) upstream.send(data) } catch {}
266
+ })
267
+
268
+ // Close handling
269
+ upstream.on('close', () => {
270
+ console.log(`[TerminalServer] WSL proxy upstream closed for '${sessionName}'`)
271
+ try { clientWs.close() } catch {}
272
+ })
273
+
274
+ clientWs.on('close', () => {
275
+ console.log(`[TerminalServer] WSL proxy client disconnected for '${sessionName}'`)
276
+ try { upstream.close() } catch {}
277
+ })
278
+
279
+ upstream.on('error', (err) => {
280
+ console.error(`[TerminalServer] WSL proxy error for '${sessionName}': ${err.message}`)
281
+ try { clientWs.close(1011, 'WSL session server unreachable') } catch {}
282
+ })
283
+
284
+ clientWs.on('error', (err) => {
285
+ console.error(`[TerminalServer] WSL proxy client error for '${sessionName}': ${err.message}`)
286
+ try { upstream.close() } catch {}
287
+ })
288
+ }
289
+
228
290
  /**
229
291
  * Stop the WebSocket terminal server.
230
292
  */
@@ -0,0 +1,503 @@
1
+ /**
2
+ * WSL Session Server
3
+ *
4
+ * Standalone Fastify + WebSocket server that runs in the target user's
5
+ * interactive session (via schtasks ONLOGON). Because it runs under
6
+ * the real user account (not LocalSystem), WSL commands work natively.
7
+ *
8
+ * minion-agent (LocalSystem, Session 0) proxies requests here for:
9
+ * - WSL terminal sessions (node-pty with wsl.exe)
10
+ * - WSL-mode chat (Claude Code CLI spawned in user context)
11
+ *
12
+ * Port: 7682 (configurable via WSL_SESSION_PORT)
13
+ * Auth: shared Bearer token from .minion/.wsl-session-token
14
+ * Protocol: ttyd binary (WebSocket), SSE (chat), JSON (HTTP API)
15
+ */
16
+
17
+ const fs = require('fs')
18
+ const path = require('path')
19
+ const http = require('http')
20
+ const { spawn } = require('child_process')
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Configuration
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
27
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || ''
28
+ const DATA_DIR = path.join(HOME_DIR, '.minion')
29
+ const TOKEN_FILE = path.join(DATA_DIR, '.wsl-session-token')
30
+ const PID_FILE = path.join(DATA_DIR, '.wsl-session.pid')
31
+
32
+ let AUTH_TOKEN = ''
33
+ try {
34
+ AUTH_TOKEN = fs.readFileSync(TOKEN_FILE, 'utf-8').trim()
35
+ } catch {
36
+ console.error('[WSL] WARNING: Could not read auth token from', TOKEN_FILE)
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Auth helper
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function verifyToken(request) {
44
+ if (!AUTH_TOKEN) return true // no token = open (development)
45
+ const header = request.headers.authorization || ''
46
+ const token = header.startsWith('Bearer ') ? header.slice(7) : ''
47
+ return token === AUTH_TOKEN
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // node-pty loader
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function loadNodePty() {
55
+ try { return require('node-pty-prebuilt-multiarch') } catch {}
56
+ try { return require('node-pty') } catch {}
57
+ return null
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Terminal sessions
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const activeSessions = new Map()
65
+
66
+ // ttyd message types (ASCII character codes)
67
+ const MSG_INPUT = 0x30 // '0'
68
+ const MSG_RESIZE = 0x31 // '1'
69
+ const MSG_OUTPUT = 0x30 // '0'
70
+ const MSG_SET_TITLE = 0x31 // '1'
71
+
72
+ function createWslSession(sessionName, command) {
73
+ const pty = loadNodePty()
74
+ if (!pty) throw new Error('node-pty is not installed')
75
+
76
+ const shell = 'wsl.exe'
77
+ const shellArgs = command ? ['-e', 'bash', '-c', command] : []
78
+
79
+ const ptyProcess = pty.spawn(shell, shellArgs, {
80
+ name: 'xterm-256color',
81
+ cols: 120,
82
+ rows: 30,
83
+ cwd: HOME_DIR,
84
+ env: process.env,
85
+ })
86
+
87
+ const session = {
88
+ pty: ptyProcess,
89
+ buffer: '',
90
+ completed: false,
91
+ exitCode: null,
92
+ startedAt: new Date().toISOString(),
93
+ wsClients: new Set(),
94
+ }
95
+
96
+ ptyProcess.onData((data) => {
97
+ session.buffer += data
98
+ if (session.buffer.length > 1024 * 1024) {
99
+ session.buffer = session.buffer.slice(-512 * 1024)
100
+ }
101
+ const outBuf = Buffer.alloc(1 + Buffer.byteLength(data))
102
+ outBuf[0] = MSG_OUTPUT
103
+ outBuf.write(data, 1)
104
+ for (const ws of session.wsClients) {
105
+ try { if (ws.readyState === 1) ws.send(outBuf) } catch {}
106
+ }
107
+ })
108
+
109
+ ptyProcess.onExit(({ exitCode }) => {
110
+ session.completed = true
111
+ session.exitCode = exitCode
112
+ for (const ws of session.wsClients) {
113
+ try { ws.close() } catch {}
114
+ }
115
+ })
116
+
117
+ activeSessions.set(sessionName, session)
118
+ console.log(`[WSL] Created session '${sessionName}' (PID: ${ptyProcess.pid})`)
119
+ return session
120
+ }
121
+
122
+ const specialKeyMap = {
123
+ 'Enter': '\r', 'Escape': '\x1b', 'Tab': '\t',
124
+ 'C-c': '\x03', 'C-d': '\x04', 'C-z': '\x1a',
125
+ 'Up': '\x1b[A', 'Down': '\x1b[B', 'Left': '\x1b[D', 'Right': '\x1b[C',
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Chat (Claude Code CLI spawn in user context)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ let activeChatChild = null
133
+
134
+ function getLlmBinary() {
135
+ const cmd = process.env.LLM_COMMAND
136
+ if (!cmd) return null
137
+ return cmd.split(/\s+/)[0]
138
+ }
139
+
140
+ /**
141
+ * Stream LLM CLI output as SSE events.
142
+ * Same logic as win/routes/chat.js streamLlmResponse(), but runs in user session.
143
+ */
144
+ function streamLlmResponse(res, prompt, sessionId) {
145
+ return new Promise((resolve, reject) => {
146
+ const binaryName = getLlmBinary()
147
+ if (!binaryName) {
148
+ reject(new Error('LLM_COMMAND is not configured'))
149
+ return
150
+ }
151
+ const binaryPath = path.join(HOME_DIR, '.local', 'bin', binaryName)
152
+ const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
153
+
154
+ const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
155
+ if (sessionId) args.push('--resume', sessionId)
156
+
157
+ console.log(`[WSL-Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new)'}`)
158
+
159
+ const child = spawn(binary, args, {
160
+ cwd: HOME_DIR,
161
+ stdio: ['pipe', 'pipe', 'pipe'],
162
+ timeout: 600000,
163
+ shell: true,
164
+ })
165
+
166
+ activeChatChild = child
167
+ child.stdin.write(prompt)
168
+ child.stdin.end()
169
+
170
+ console.log(`[WSL-Chat] child PID: ${child.pid}`)
171
+
172
+ let lineBuffer = ''
173
+ let currentBlockType = null
174
+ let currentToolName = null
175
+ let toolInputBuffer = ''
176
+
177
+ child.stdout.on('data', (data) => {
178
+ lineBuffer += data.toString()
179
+ const parts = lineBuffer.split('\n')
180
+ lineBuffer = parts.pop() || ''
181
+
182
+ for (const line of parts) {
183
+ if (!line.trim()) continue
184
+ // Forward raw JSON lines as SSE — minion-agent handles parsing
185
+ res.write(`data: ${line}\n\n`)
186
+ }
187
+ })
188
+
189
+ child.stderr.on('data', (data) => {
190
+ console.error(`[WSL-Chat] stderr: ${data.toString()}`)
191
+ })
192
+
193
+ child.on('close', (code) => {
194
+ activeChatChild = null
195
+ // Flush remaining buffer
196
+ if (lineBuffer.trim()) {
197
+ res.write(`data: ${lineBuffer}\n\n`)
198
+ }
199
+ res.write(`data: ${JSON.stringify({ type: 'wsl_chat_done', exit_code: code })}\n\n`)
200
+ resolve()
201
+ })
202
+
203
+ child.on('error', (err) => {
204
+ activeChatChild = null
205
+ res.write(`data: ${JSON.stringify({ type: 'error', error: `Failed to start CLI: ${err.message}` })}\n\n`)
206
+ reject(err)
207
+ })
208
+
209
+ res.on('close', () => {
210
+ if (child && !child.killed) child.kill('SIGTERM')
211
+ })
212
+ })
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Fastify HTTP server
217
+ // ---------------------------------------------------------------------------
218
+
219
+ async function startServer() {
220
+ const fastify = require('fastify')({ logger: true })
221
+
222
+ // --- Health (no auth) ---
223
+ fastify.get('/api/wsl/health', async () => {
224
+ return { success: true, service: 'wsl-session-server', pid: process.pid }
225
+ })
226
+
227
+ // --- Terminal: list sessions ---
228
+ fastify.get('/api/wsl/sessions', async (request, reply) => {
229
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
230
+ const sessions = []
231
+ for (const [name, session] of activeSessions) {
232
+ sessions.push({
233
+ name,
234
+ type: 'wsl',
235
+ completed: session.completed,
236
+ exit_code: session.exitCode,
237
+ started_at: session.startedAt,
238
+ })
239
+ }
240
+ return { success: true, sessions }
241
+ })
242
+
243
+ // --- Terminal: create session ---
244
+ fastify.post('/api/wsl/create', async (request, reply) => {
245
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
246
+ const { name, command } = request.body || {}
247
+ const sessionName = name || `wsl-session-${Date.now()}`
248
+
249
+ if (!/^[\w-]+$/.test(sessionName)) {
250
+ reply.code(400); return { success: false, error: 'Invalid session name' }
251
+ }
252
+ if (activeSessions.has(sessionName)) {
253
+ reply.code(409); return { success: false, error: `Session '${sessionName}' already exists` }
254
+ }
255
+
256
+ try {
257
+ createWslSession(sessionName, command)
258
+ return { success: true, session: sessionName, message: `WSL session '${sessionName}' created` }
259
+ } catch (err) {
260
+ reply.code(500); return { success: false, error: err.message }
261
+ }
262
+ })
263
+
264
+ // --- Terminal: send input ---
265
+ fastify.post('/api/wsl/send', async (request, reply) => {
266
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
267
+ const { session: sessionName, input, enter = false, special } = request.body || {}
268
+ if (!sessionName) { reply.code(400); return { success: false, error: 'session is required' } }
269
+ if (!input && !special) { reply.code(400); return { success: false, error: 'input or special key is required' } }
270
+
271
+ const session = activeSessions.get(sessionName)
272
+ if (!session || !session.pty) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
273
+ if (session.completed) { reply.code(400); return { success: false, error: `Session '${sessionName}' has already exited` } }
274
+
275
+ try {
276
+ if (special) {
277
+ const key = specialKeyMap[special]
278
+ if (!key) { reply.code(400); return { success: false, error: `Invalid special key. Allowed: ${Object.keys(specialKeyMap).join(', ')}` } }
279
+ session.pty.write(key)
280
+ } else {
281
+ session.pty.write(input)
282
+ if (enter) session.pty.write('\r')
283
+ }
284
+ return { success: true, session: sessionName }
285
+ } catch (err) {
286
+ reply.code(500); return { success: false, error: err.message }
287
+ }
288
+ })
289
+
290
+ // --- Terminal: kill session ---
291
+ fastify.post('/api/wsl/kill', async (request, reply) => {
292
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
293
+ const { session: sessionName } = request.body || {}
294
+ if (!sessionName) { reply.code(400); return { success: false, error: 'session is required' } }
295
+
296
+ const session = activeSessions.get(sessionName)
297
+ if (!session) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
298
+
299
+ try {
300
+ if (session.pty && !session.completed) session.pty.kill()
301
+ activeSessions.delete(sessionName)
302
+ return { success: true, session: sessionName, message: `Session '${sessionName}' terminated` }
303
+ } catch (err) {
304
+ reply.code(500); return { success: false, error: err.message }
305
+ }
306
+ })
307
+
308
+ // --- Terminal: capture buffer ---
309
+ fastify.get('/api/wsl/capture', async (request, reply) => {
310
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
311
+ const { session: sessionName, lines = '100' } = request.query || {}
312
+ if (!sessionName) { reply.code(400); return { success: false, error: 'session parameter is required' } }
313
+
314
+ const session = activeSessions.get(sessionName)
315
+ if (!session) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
316
+
317
+ const lineCount = Math.min(Math.max(parseInt(lines) || 100, 1), 1000)
318
+ const allLines = session.buffer.split('\n')
319
+ const content = allLines.slice(-lineCount).join('\n')
320
+
321
+ return { success: true, session: sessionName, content, lines: lineCount, timestamp: Date.now() }
322
+ })
323
+
324
+ // --- Chat: SSE stream ---
325
+ fastify.post('/api/wsl/chat', async (request, reply) => {
326
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
327
+ const { prompt, session_id } = request.body || {}
328
+ if (!prompt) { reply.code(400); return { success: false, error: 'prompt is required' } }
329
+
330
+ reply.hijack()
331
+ reply.raw.writeHead(200, {
332
+ 'Content-Type': 'text/event-stream',
333
+ 'Cache-Control': 'no-cache',
334
+ 'Connection': 'keep-alive',
335
+ })
336
+ reply.raw.flushHeaders()
337
+
338
+ try {
339
+ await streamLlmResponse(reply.raw, prompt, session_id)
340
+ } catch (err) {
341
+ console.error('[WSL-Chat] stream error:', err.message)
342
+ reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`)
343
+ }
344
+ reply.raw.end()
345
+ })
346
+
347
+ // --- Chat: abort ---
348
+ fastify.post('/api/wsl/chat/abort', async (request, reply) => {
349
+ if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
350
+ if (!activeChatChild) {
351
+ return { success: false, error: 'No active WSL chat process' }
352
+ }
353
+ console.log(`[WSL-Chat] Aborting PID: ${activeChatChild.pid}`)
354
+ activeChatChild.kill('SIGTERM')
355
+ const pid = activeChatChild.pid
356
+ setTimeout(() => {
357
+ try {
358
+ if (activeChatChild && activeChatChild.pid === pid) activeChatChild.kill('SIGKILL')
359
+ } catch {}
360
+ }, 2000)
361
+ return { success: true }
362
+ })
363
+
364
+ // --- Start HTTP + WebSocket ---
365
+ await fastify.listen({ port: WSL_PORT, host: '127.0.0.1' })
366
+ console.log(`[WSL] HTTP server listening on 127.0.0.1:${WSL_PORT}`)
367
+
368
+ // WebSocket server on a separate HTTP server (same port pattern as terminal-server.js)
369
+ startWebSocketServer()
370
+
371
+ // Write PID file
372
+ try {
373
+ fs.writeFileSync(PID_FILE, String(process.pid))
374
+ } catch {}
375
+
376
+ return fastify
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // WebSocket terminal server (ttyd protocol)
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function startWebSocketServer() {
384
+ let WebSocket
385
+ try { WebSocket = require('ws') } catch {
386
+ console.error('[WSL] ws module not installed, WebSocket terminal disabled')
387
+ return
388
+ }
389
+
390
+ // Use a secondary HTTP server for WebSocket on WSL_PORT + 1 (7683)
391
+ // to avoid conflicts with the Fastify server on WSL_PORT.
392
+ // Alternatively, minion-agent's terminal-server.js proxies to us on WSL_PORT WS.
393
+ // For simplicity, we attach WS to the same port via a raw HTTP server.
394
+ const wsPort = WSL_PORT + 1
395
+ const httpServer = http.createServer((req, res) => {
396
+ res.writeHead(426, { 'Content-Type': 'text/plain' })
397
+ res.end('WebSocket connections only')
398
+ })
399
+
400
+ const wss = new WebSocket.Server({ server: httpServer })
401
+
402
+ wss.on('connection', (ws, req) => {
403
+ // Verify token from query string
404
+ const url = new URL(req.url, `http://localhost:${wsPort}`)
405
+ const token = url.searchParams.get('token') || ''
406
+ if (AUTH_TOKEN && token !== AUTH_TOKEN) {
407
+ ws.close(1008, 'Unauthorized')
408
+ return
409
+ }
410
+
411
+ const sessionMatch = req.url?.match(/\/ws\/([^/?]+)/)
412
+ const sessionName = sessionMatch ? sessionMatch[1] : null
413
+
414
+ if (!sessionName || !/^[\w-]+$/.test(sessionName)) {
415
+ ws.close(1008, 'Invalid session name')
416
+ return
417
+ }
418
+
419
+ let session = activeSessions.get(sessionName)
420
+ if (!session || session.completed) {
421
+ // Auto-create WSL session
422
+ try {
423
+ session = createWslSession(sessionName)
424
+ } catch (err) {
425
+ ws.close(1011, err.message)
426
+ return
427
+ }
428
+ }
429
+
430
+ session.wsClients.add(ws)
431
+
432
+ // Send title
433
+ const titleMsg = Buffer.from([MSG_SET_TITLE, ...Buffer.from(JSON.stringify({ title: sessionName }))])
434
+ ws.send(titleMsg)
435
+
436
+ // Replay buffer
437
+ if (session.buffer.length > 0) {
438
+ const outBuf = Buffer.alloc(1 + Buffer.byteLength(session.buffer))
439
+ outBuf[0] = MSG_OUTPUT
440
+ outBuf.write(session.buffer, 1)
441
+ try { ws.send(outBuf) } catch {}
442
+ }
443
+
444
+ ws.on('message', (data) => {
445
+ if (!session.pty || session.completed) return
446
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
447
+ if (buf.length < 1) return
448
+ const type = buf[0]
449
+ const payload = buf.slice(1)
450
+
451
+ if (type === MSG_INPUT) {
452
+ session.pty.write(payload.toString())
453
+ } else if (type === MSG_RESIZE) {
454
+ try {
455
+ const { columns, rows } = JSON.parse(payload.toString())
456
+ if (columns && rows) session.pty.resize(columns, rows)
457
+ } catch {}
458
+ }
459
+ })
460
+
461
+ ws.on('close', () => { session.wsClients.delete(ws) })
462
+ ws.on('error', () => { session.wsClients.delete(ws) })
463
+ })
464
+
465
+ httpServer.listen(wsPort, '127.0.0.1', () => {
466
+ console.log(`[WSL] WebSocket terminal server listening on 127.0.0.1:${wsPort}`)
467
+ })
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Graceful shutdown
472
+ // ---------------------------------------------------------------------------
473
+
474
+ function shutdown(signal) {
475
+ console.log(`[WSL] Received ${signal}, shutting down...`)
476
+ for (const [name, session] of activeSessions) {
477
+ try {
478
+ if (session.pty && !session.completed) session.pty.kill()
479
+ } catch {}
480
+ }
481
+ activeSessions.clear()
482
+ if (activeChatChild) {
483
+ try { activeChatChild.kill('SIGTERM') } catch {}
484
+ }
485
+ try { fs.unlinkSync(PID_FILE) } catch {}
486
+ process.exit(0)
487
+ }
488
+
489
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
490
+ process.on('SIGINT', () => shutdown('SIGINT'))
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Entry
494
+ // ---------------------------------------------------------------------------
495
+
496
+ if (require.main === module) {
497
+ startServer().catch((err) => {
498
+ console.error('[WSL] Failed to start:', err)
499
+ process.exit(1)
500
+ })
501
+ }
502
+
503
+ module.exports = { startServer }