@geekbeer/minion 3.58.0 → 3.59.3
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/meetings/meeting-mcp-server.js +244 -0
- package/core/meetings/meeting-prompt.js +49 -0
- package/core/routes/meetings.js +111 -0
- package/docs/api-reference.md +25 -0
- package/docs/task-guides.md +28 -0
- package/linux/meeting-runner.js +215 -0
- package/linux/server.js +6 -0
- package/package.json +1 -1
- package/rules/core.md +32 -0
- package/win/meeting-runner.js +165 -0
- package/win/minion-cli.ps1 +76 -1
- package/win/server.js +5 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Meeting MCP server (stdio).
|
|
4
|
+
*
|
|
5
|
+
* Spawned by meeting-runner per meeting. Bridges the in-tmux claude process
|
|
6
|
+
* to the HQ meeting endpoints. Exposes four tools:
|
|
7
|
+
*
|
|
8
|
+
* meeting_wait_for_next_message(timeout_ms?)
|
|
9
|
+
* Long-polls HQ for new messages since the last call. Returns either
|
|
10
|
+
* { messages: [...], history_count } or { meeting_ended: true }.
|
|
11
|
+
*
|
|
12
|
+
* meeting_speak(text)
|
|
13
|
+
* Posts a message to the meeting from this minion.
|
|
14
|
+
*
|
|
15
|
+
* meeting_get_state()
|
|
16
|
+
* Returns meeting metadata + participant list.
|
|
17
|
+
*
|
|
18
|
+
* meeting_leave()
|
|
19
|
+
* Records left_at for this minion. Returns { ok: true }; claude should
|
|
20
|
+
* exit its participation loop after this call.
|
|
21
|
+
*
|
|
22
|
+
* Environment (passed by meeting-runner):
|
|
23
|
+
* MINION_MEETING_ID - meeting UUID
|
|
24
|
+
* HQ_URL - HQ server URL
|
|
25
|
+
* API_TOKEN - minion's API token (Bearer)
|
|
26
|
+
*
|
|
27
|
+
* Protocol: JSON-RPC 2.0 over stdio.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const readline = require('readline')
|
|
31
|
+
|
|
32
|
+
const HQ_URL = process.env.HQ_URL
|
|
33
|
+
const API_TOKEN = process.env.API_TOKEN
|
|
34
|
+
const MEETING_ID = process.env.MINION_MEETING_ID
|
|
35
|
+
|
|
36
|
+
if (!HQ_URL || !API_TOKEN || !MEETING_ID) {
|
|
37
|
+
process.stderr.write('[meeting-mcp] Missing required env: HQ_URL / API_TOKEN / MINION_MEETING_ID\n')
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let lastSeenAt = null
|
|
42
|
+
|
|
43
|
+
async function hqFetch(path, options = {}) {
|
|
44
|
+
const url = `${HQ_URL}${path}`
|
|
45
|
+
const resp = await fetch(url, {
|
|
46
|
+
...options,
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
Authorization: `Bearer ${API_TOKEN}`,
|
|
50
|
+
...(options.headers || {}),
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
let data = null
|
|
54
|
+
try {
|
|
55
|
+
data = await resp.json()
|
|
56
|
+
} catch {
|
|
57
|
+
data = {}
|
|
58
|
+
}
|
|
59
|
+
return { resp, data }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function waitForNextMessage() {
|
|
63
|
+
const params = new URLSearchParams({ wait: 'true' })
|
|
64
|
+
if (lastSeenAt) params.set('after', lastSeenAt)
|
|
65
|
+
const { resp, data } = await hqFetch(
|
|
66
|
+
`/api/minion/meetings/${MEETING_ID}/messages?${params}`,
|
|
67
|
+
)
|
|
68
|
+
if (resp.status === 404 || data.meeting_ended) {
|
|
69
|
+
return { meeting_ended: true }
|
|
70
|
+
}
|
|
71
|
+
const messages = data.messages || []
|
|
72
|
+
if (messages.length > 0) {
|
|
73
|
+
lastSeenAt = messages[messages.length - 1].created_at
|
|
74
|
+
}
|
|
75
|
+
return { messages, history_count: messages.length }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function speak(text) {
|
|
79
|
+
const { resp, data } = await hqFetch(
|
|
80
|
+
`/api/minion/meetings/${MEETING_ID}/messages`,
|
|
81
|
+
{ method: 'POST', body: JSON.stringify({ text }) },
|
|
82
|
+
)
|
|
83
|
+
if (!resp.ok) {
|
|
84
|
+
throw new Error(data.error || `speak failed: ${resp.status}`)
|
|
85
|
+
}
|
|
86
|
+
return { message_id: data.message?.id }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function getState() {
|
|
90
|
+
const { resp, data } = await hqFetch(`/api/minion/meetings/${MEETING_ID}`)
|
|
91
|
+
if (resp.status === 404) {
|
|
92
|
+
return { meeting_ended: true }
|
|
93
|
+
}
|
|
94
|
+
if (!resp.ok) {
|
|
95
|
+
throw new Error(data.error || `state failed: ${resp.status}`)
|
|
96
|
+
}
|
|
97
|
+
return data
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function leave() {
|
|
101
|
+
await hqFetch(`/api/minion/meetings/${MEETING_ID}`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
body: JSON.stringify({ action: 'leave' }),
|
|
104
|
+
})
|
|
105
|
+
return { ok: true }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Tool definitions --------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
function toolsList() {
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
name: 'meeting_wait_for_next_message',
|
|
114
|
+
description:
|
|
115
|
+
'Long-poll for the next message in the meeting (up to ~25s). ' +
|
|
116
|
+
'Returns { messages: [...], history_count } when new messages arrive, ' +
|
|
117
|
+
'or { meeting_ended: true } if the meeting has been ended by the host. ' +
|
|
118
|
+
'Use this in a loop to listen to the meeting.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'meeting_speak',
|
|
126
|
+
description:
|
|
127
|
+
'Say something in the meeting. Your message will be visible to all participants. ' +
|
|
128
|
+
'Keep it conversational and short (1-3 sentences) unless the discussion requires depth.',
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
text: { type: 'string', description: 'What you want to say.' },
|
|
133
|
+
},
|
|
134
|
+
required: ['text'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'meeting_get_state',
|
|
139
|
+
description:
|
|
140
|
+
'Get meeting metadata (title, purpose, participants). Useful at session start.',
|
|
141
|
+
inputSchema: { type: 'object', properties: {} },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'meeting_leave',
|
|
145
|
+
description:
|
|
146
|
+
'Leave the meeting. Call this when the meeting is over or you have been ' +
|
|
147
|
+
'explicitly excused. After calling this, exit your participation loop.',
|
|
148
|
+
inputSchema: { type: 'object', properties: {} },
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function handleToolCall(name, args) {
|
|
154
|
+
switch (name) {
|
|
155
|
+
case 'meeting_wait_for_next_message':
|
|
156
|
+
return waitForNextMessage()
|
|
157
|
+
case 'meeting_speak':
|
|
158
|
+
return speak(String(args.text || ''))
|
|
159
|
+
case 'meeting_get_state':
|
|
160
|
+
return getState()
|
|
161
|
+
case 'meeting_leave':
|
|
162
|
+
return leave()
|
|
163
|
+
default:
|
|
164
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- JSON-RPC transport ------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function send(msg) {
|
|
171
|
+
process.stdout.write(JSON.stringify(msg) + '\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendResult(id, result) {
|
|
175
|
+
send({ jsonrpc: '2.0', id, result })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sendError(id, code, message) {
|
|
179
|
+
send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function dispatch(msg) {
|
|
183
|
+
const { id, method, params } = msg
|
|
184
|
+
try {
|
|
185
|
+
switch (method) {
|
|
186
|
+
case 'initialize':
|
|
187
|
+
sendResult(id, {
|
|
188
|
+
protocolVersion: '2024-11-05',
|
|
189
|
+
serverInfo: { name: 'minion-meeting', version: '1.0.0' },
|
|
190
|
+
capabilities: { tools: {} },
|
|
191
|
+
})
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
case 'notifications/initialized':
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
case 'tools/list':
|
|
198
|
+
sendResult(id, { tools: toolsList() })
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
case 'tools/call': {
|
|
202
|
+
const { name, arguments: args } = params || {}
|
|
203
|
+
const result = await handleToolCall(name, args || {})
|
|
204
|
+
sendResult(id, {
|
|
205
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
206
|
+
isError: false,
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'ping':
|
|
212
|
+
sendResult(id, {})
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
default:
|
|
216
|
+
if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (id !== undefined) sendError(id, -32000, err.message || String(err))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function start() {
|
|
225
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false })
|
|
226
|
+
rl.on('line', (line) => {
|
|
227
|
+
const trimmed = line.trim()
|
|
228
|
+
if (!trimmed) return
|
|
229
|
+
let msg
|
|
230
|
+
try {
|
|
231
|
+
msg = JSON.parse(trimmed)
|
|
232
|
+
} catch {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
dispatch(msg)
|
|
236
|
+
})
|
|
237
|
+
rl.on('close', () => process.exit(0))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (require.main === module) {
|
|
241
|
+
start()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { start, toolsList, handleToolCall }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the kickoff prompt injected into the claude process when joining
|
|
3
|
+
* a meeting. The prompt:
|
|
4
|
+
* 1. Explains the meeting context (title, purpose, participants).
|
|
5
|
+
* 2. Instructs claude to use the meeting MCP tools to participate.
|
|
6
|
+
* 3. Hands control to claude to run its own participation loop.
|
|
7
|
+
*
|
|
8
|
+
* Output behavior (sprint plan, retro notes, etc.) is intentionally NOT
|
|
9
|
+
* hardcoded here — that lives in role-specific guidance (PM core.md etc.)
|
|
10
|
+
* so different meeting purposes can produce different artifacts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function buildMeetingKickoffPrompt({ meetingId, title, purpose, host, selfName, role }) {
|
|
14
|
+
const lines = [
|
|
15
|
+
`[ミーティング参加] [meeting:${meetingId}]`,
|
|
16
|
+
`あなたは「${selfName}」として、進行中のミーティングに招待されました。`,
|
|
17
|
+
'',
|
|
18
|
+
'## ミーティング情報',
|
|
19
|
+
`- タイトル: ${title}`,
|
|
20
|
+
purpose ? `- 目的: ${purpose}` : null,
|
|
21
|
+
host ? `- ホスト: ${host}` : null,
|
|
22
|
+
role ? `- あなたの役割: ${role}` : null,
|
|
23
|
+
'',
|
|
24
|
+
'## 参加方法',
|
|
25
|
+
'meeting MCP ツールを使って参加してください。基本ループ:',
|
|
26
|
+
'',
|
|
27
|
+
'1. `meeting_get_state` でミーティングの状態と参加者を確認',
|
|
28
|
+
'2. `meeting_wait_for_next_message` で次の発言を待つ (long-poll, 最大25秒)',
|
|
29
|
+
'3. 受け取ったメッセージに対して、応答すべきか判断する',
|
|
30
|
+
' - 自分の役割・コンテキストに関連する話題なら応答する',
|
|
31
|
+
' - 直前の発言が他の参加者宛 (mention) なら基本的にスルー',
|
|
32
|
+
' - 重複・冗長な発言は避ける (他のミニオンが既に同趣旨を発言していたら追従しない)',
|
|
33
|
+
'4. 応答する場合は `meeting_speak` で 1-3 文の短い発言を送る',
|
|
34
|
+
'5. `meeting_wait_for_next_message` の戻り値が `{ meeting_ended: true }` になったら',
|
|
35
|
+
' `meeting_leave` を呼んでループを終了する',
|
|
36
|
+
'',
|
|
37
|
+
'## 発言ガイドライン',
|
|
38
|
+
'- 1回の発言は **2-3 文以内** を基本とする (会議でだらだら話さない)',
|
|
39
|
+
'- 議論を前進させる発言を優先する (賛否・確認・代替案・質問)',
|
|
40
|
+
'- 既存のあなたのタスク・プロジェクト状況を知っていれば、それを踏まえて発言する',
|
|
41
|
+
'- 役割に応じた成果物がある場合 (PM=議事録/sprint計画 等) は、',
|
|
42
|
+
' あなたの core ルールに従って終了前に作成する',
|
|
43
|
+
'',
|
|
44
|
+
'では `meeting_get_state` から開始してください。',
|
|
45
|
+
].filter(Boolean)
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { buildMeetingKickoffPrompt }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meetings routes (experimental)
|
|
3
|
+
*
|
|
4
|
+
* Receives meeting lifecycle pushes from HQ:
|
|
5
|
+
* POST /api/meetings/invitations — HQ invites this minion to a meeting
|
|
6
|
+
* DELETE /api/meetings/:id — HQ notifies the meeting has ended
|
|
7
|
+
* GET /api/meetings/active — Debug: list currently-active sessions
|
|
8
|
+
*
|
|
9
|
+
* The actual participation (waiting for messages, speaking) happens inside
|
|
10
|
+
* the spawned tmux session via the meeting MCP server — HQ never controls
|
|
11
|
+
* the minion's thoughts, it just opens and closes the meeting room.
|
|
12
|
+
*
|
|
13
|
+
* Platform-specific runner is dependency-injected via fastify opts so the
|
|
14
|
+
* same route module works on Linux (tmux) and Windows (WSL).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { verifyToken } = require('../lib/auth')
|
|
18
|
+
|
|
19
|
+
async function meetingRoutes(fastify, opts) {
|
|
20
|
+
const meetingRunner = opts.meetingRunner
|
|
21
|
+
if (!meetingRunner) {
|
|
22
|
+
throw new Error('meetingRoutes requires { meetingRunner } in opts')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POST /api/meetings/invitations
|
|
27
|
+
* Body: { meeting_id, title, purpose, hq_url, host }
|
|
28
|
+
* Spawns a tmux session that runs claude with the meeting MCP server.
|
|
29
|
+
*/
|
|
30
|
+
fastify.post('/api/meetings/invitations', async (request, reply) => {
|
|
31
|
+
if (!verifyToken(request)) {
|
|
32
|
+
reply.code(401)
|
|
33
|
+
return { success: false, error: 'Unauthorized' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const body = request.body || {}
|
|
37
|
+
const { meeting_id, title, purpose, hq_url, host, self_name, role } = body
|
|
38
|
+
if (!meeting_id || !title || !hq_url) {
|
|
39
|
+
reply.code(400)
|
|
40
|
+
return {
|
|
41
|
+
success: false,
|
|
42
|
+
error: 'meeting_id, title, and hq_url are required',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await meetingRunner.runMeeting({
|
|
48
|
+
meetingId: meeting_id,
|
|
49
|
+
title,
|
|
50
|
+
purpose: purpose || null,
|
|
51
|
+
host: host || 'host',
|
|
52
|
+
hqUrl: hq_url,
|
|
53
|
+
selfName: self_name || null,
|
|
54
|
+
role: role || null,
|
|
55
|
+
})
|
|
56
|
+
if (!result.success) {
|
|
57
|
+
reply.code(500)
|
|
58
|
+
return { success: false, error: result.error || 'Failed to start meeting session' }
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
success: true,
|
|
62
|
+
session_name: result.sessionName,
|
|
63
|
+
started: result.started,
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(`[Meetings] Failed to start meeting: ${err.message}`)
|
|
67
|
+
reply.code(500)
|
|
68
|
+
return { success: false, error: err.message }
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* DELETE /api/meetings/:id
|
|
74
|
+
* HQ notifies that the meeting has been ended/deleted. Kill the tmux session.
|
|
75
|
+
*/
|
|
76
|
+
fastify.delete('/api/meetings/:id', async (request, reply) => {
|
|
77
|
+
if (!verifyToken(request)) {
|
|
78
|
+
reply.code(401)
|
|
79
|
+
return { success: false, error: 'Unauthorized' }
|
|
80
|
+
}
|
|
81
|
+
const meetingId = request.params.id
|
|
82
|
+
try {
|
|
83
|
+
const killed = await meetingRunner.killMeetingSession(meetingId)
|
|
84
|
+
return { success: true, killed }
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`[Meetings] Failed to kill meeting ${meetingId}: ${err.message}`)
|
|
87
|
+
reply.code(500)
|
|
88
|
+
return { success: false, error: err.message }
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* GET /api/meetings/active
|
|
94
|
+
* Debug: list active meeting tmux sessions.
|
|
95
|
+
*/
|
|
96
|
+
fastify.get('/api/meetings/active', async (request, reply) => {
|
|
97
|
+
if (!verifyToken(request)) {
|
|
98
|
+
reply.code(401)
|
|
99
|
+
return { success: false, error: 'Unauthorized' }
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const sessions = await meetingRunner.listMeetingSessions()
|
|
103
|
+
return { success: true, sessions }
|
|
104
|
+
} catch (err) {
|
|
105
|
+
reply.code(500)
|
|
106
|
+
return { success: false, error: err.message }
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { meetingRoutes }
|
package/docs/api-reference.md
CHANGED
|
@@ -767,6 +767,18 @@ Web ページの読み取り・要約・情報抽出をミニオン内のサブ
|
|
|
767
767
|
|
|
768
768
|
Available commands: `restart-agent`, `update-agent`, `restart-display`, `restart-all`, `status-services`
|
|
769
769
|
|
|
770
|
+
### Meeting Rooms 🧪 (experimental, v3.59.0〜)
|
|
771
|
+
|
|
772
|
+
HQ がミーティング招待を直接ミニオンへプッシュするエンドポイント。受信すると `mt-{meetingId8}` 名の tmux セッションを起動し、claude + meeting MCP サーバーで会議に参加する。
|
|
773
|
+
|
|
774
|
+
| Method | Endpoint | Description |
|
|
775
|
+
|--------|----------|-------------|
|
|
776
|
+
| POST | `/api/meetings/invitations` | HQからの招待受信。Body: `{meeting_id, title, purpose, hq_url, host, self_name?, role?}` |
|
|
777
|
+
| DELETE | `/api/meetings/:id` | HQからの終了通知 (tmuxセッションkill) |
|
|
778
|
+
| GET | `/api/meetings/active` | デバッグ: アクティブな会議セッション一覧 |
|
|
779
|
+
|
|
780
|
+
参加後の動作 (`meeting` MCPツール) は core.md の「Meeting Participation」を参照。
|
|
781
|
+
|
|
770
782
|
### Admin (HQ → Minion only)
|
|
771
783
|
|
|
772
784
|
HQ が課金状態の変化に応じてミニオンに直接プッシュするエンドポイント。
|
|
@@ -1974,3 +1986,16 @@ hq note list <project_id>
|
|
|
1974
1986
|
hq note get <project_id> <note_id>
|
|
1975
1987
|
hq note search <project_id> "キーワード"
|
|
1976
1988
|
```
|
|
1989
|
+
|
|
1990
|
+
### Meeting Rooms 🧪 (HQ, experimental, v3.59.0〜)
|
|
1991
|
+
|
|
1992
|
+
ミーティング機能はミニオン上の `meeting` MCP サーバーが叩く HQ エンドポイント。通常 AI 側から直接コールすることはなく、招待 (`POST /api/meetings/invitations` をミニオンが受信) で起動された claude セッションが MCP ツール経由で利用する。
|
|
1993
|
+
|
|
1994
|
+
| Method | Endpoint | Description |
|
|
1995
|
+
|--------|----------|-------------|
|
|
1996
|
+
| GET | `/api/minion/meetings/:id` | ミーティングのメタデータ + 参加者一覧。`meeting_get_state` ツールから呼ばれる |
|
|
1997
|
+
| GET | `/api/minion/meetings/:id/messages?after=<iso>&wait=true` | long-poll で次の発言を待機 (最大 ~25秒)。404 = 会議終了 |
|
|
1998
|
+
| POST | `/api/minion/meetings/:id/messages` | 発言。Body: `{text}` |
|
|
1999
|
+
| POST | `/api/minion/meetings/:id` | アクション。Body: `{action: 'leave' \| 'heartbeat'}` |
|
|
2000
|
+
|
|
2001
|
+
これらは MCP ツール (`meeting_wait_for_next_message`, `meeting_speak`, `meeting_leave`) として claude に公開される。直接叩く必要はない。
|
package/docs/task-guides.md
CHANGED
|
@@ -793,6 +793,34 @@ await reportIssue({ title: '...', body: '...', labels: ['bug'] })
|
|
|
793
793
|
|
|
794
794
|
---
|
|
795
795
|
|
|
796
|
+
## ミーティングへの参加 🧪 (experimental, v3.59.0〜)
|
|
797
|
+
|
|
798
|
+
HQから招待されると、`mt-{meetingId8}` 名の tmux セッションで claude が自動起動して `meeting` MCP サーバー経由で会議に参加する。あなた自身がコマンドを叩いて参加・退出する必要は無い。
|
|
799
|
+
|
|
800
|
+
**セッション中のあなたの責任:**
|
|
801
|
+
|
|
802
|
+
1. `meeting_get_state` で目的・参加者を確認 (起動直後に呼ぶ)
|
|
803
|
+
2. `meeting_wait_for_next_message` をループで呼んで発言を聞く
|
|
804
|
+
3. 自分が応答すべき内容なら `meeting_speak` で発言 (1-3文)
|
|
805
|
+
4. 戻り値が `meeting_ended: true` になったら `meeting_leave` を呼んでループ脱出
|
|
806
|
+
|
|
807
|
+
**振る舞い指針:** core.md の「Meeting Participation」を参照。特に:
|
|
808
|
+
- 発言は短く (会議でだらだら話さない)
|
|
809
|
+
- 重複発言を避ける
|
|
810
|
+
- 議論を前進させる発言を優先 (賛否・代替案・質問)
|
|
811
|
+
- PMロールなら議事進行と成果物作成も担う
|
|
812
|
+
|
|
813
|
+
**確認方法:**
|
|
814
|
+
```bash
|
|
815
|
+
# アクティブな会議セッションを確認
|
|
816
|
+
tmux ls | grep '^mt-'
|
|
817
|
+
|
|
818
|
+
# 自分が参加中の会議の状態
|
|
819
|
+
curl -H "Authorization: Bearer $API_TOKEN" http://localhost:8080/api/meetings/active
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
セッション復帰機構は未実装。tmux/claude が落ちた場合は再起動されず、新しい招待を待つ。
|
|
823
|
+
|
|
796
824
|
## ツール・MCPサーバーのインストール
|
|
797
825
|
|
|
798
826
|
スキルが `requires` で宣言している MCP サーバーや CLI ツールが不足している場合は、`~/.minion/docs/environment-setup.md` の手順に従ってインストールする。
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting Runner (Linux)
|
|
3
|
+
*
|
|
4
|
+
* Spawns a dedicated tmux session per meeting invitation so the claude
|
|
5
|
+
* participation loop is isolated from chat / board-task sessions and can
|
|
6
|
+
* be inspected via `tmux attach -t mt-{id8}`.
|
|
7
|
+
*
|
|
8
|
+
* Session naming: `mt-{meetingId.slice(0,8)}`
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. Receive invitation from HQ (via routes/meetings POST /api/meetings/invitations).
|
|
12
|
+
* 2. If a `mt-*` session for this meeting already exists, return early
|
|
13
|
+
* (idempotent: HQ may retry pushes).
|
|
14
|
+
* 3. Write a temporary MCP config + kickoff prompt, then spawn:
|
|
15
|
+
* tmux new-session -d -s mt-xxx claude --mcp-config <tmp> <kickoff>
|
|
16
|
+
* The meeting MCP server starts as a stdio subprocess of claude.
|
|
17
|
+
* 4. tmux session exits when claude exits (after meeting_leave is called
|
|
18
|
+
* or the meeting was deleted on HQ side).
|
|
19
|
+
*
|
|
20
|
+
* No exit-code polling here (unlike board-task-runner) — meetings are
|
|
21
|
+
* fire-and-forget; observability is via `tmux ls` + meeting logs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { exec } = require('child_process')
|
|
25
|
+
const { promisify } = require('util')
|
|
26
|
+
const fs = require('fs').promises
|
|
27
|
+
const os = require('os')
|
|
28
|
+
const path = require('path')
|
|
29
|
+
const execAsync = promisify(exec)
|
|
30
|
+
|
|
31
|
+
const { config } = require('../core/config')
|
|
32
|
+
const runningTasks = require('../core/lib/running-tasks')
|
|
33
|
+
const logManager = require('../core/lib/log-manager')
|
|
34
|
+
const { getActivePrimary } = require('../core/llm-plugins/lib/active')
|
|
35
|
+
const { buildMeetingKickoffPrompt } = require('../core/meetings/meeting-prompt')
|
|
36
|
+
|
|
37
|
+
function generateSessionName(meetingId) {
|
|
38
|
+
if (!meetingId) throw new Error('meetingId is required')
|
|
39
|
+
return `mt-${String(meetingId).substring(0, 8)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function tmuxHasSession(sessionName) {
|
|
43
|
+
try {
|
|
44
|
+
await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`)
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function listMeetingSessions() {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execAsync(`tmux ls -F '#S' 2>/dev/null`)
|
|
54
|
+
return stdout
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter((s) => s.startsWith('mt-'))
|
|
58
|
+
} catch {
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function killMeetingSession(meetingId) {
|
|
64
|
+
const sessionName = generateSessionName(meetingId)
|
|
65
|
+
try {
|
|
66
|
+
await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`)
|
|
67
|
+
runningTasks.remove(sessionName)
|
|
68
|
+
return true
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Start a meeting participation tmux session.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} params
|
|
78
|
+
* @param {string} params.meetingId
|
|
79
|
+
* @param {string} params.title
|
|
80
|
+
* @param {string|null} params.purpose
|
|
81
|
+
* @param {string} params.host
|
|
82
|
+
* @param {string} params.hqUrl
|
|
83
|
+
* @param {string} [params.selfName] - This minion's display name
|
|
84
|
+
* @param {string} [params.role] - This minion's role in the project (e.g. 'pm')
|
|
85
|
+
*/
|
|
86
|
+
async function runMeeting({ meetingId, title, purpose, host, hqUrl, selfName, role }) {
|
|
87
|
+
if (!meetingId) {
|
|
88
|
+
return { sessionName: null, started: false, success: false, error: 'meetingId is required' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sessionName = generateSessionName(meetingId)
|
|
92
|
+
|
|
93
|
+
if (await tmuxHasSession(sessionName)) {
|
|
94
|
+
console.log(`[MeetingRunner] tmux session ${sessionName} already exists, skipping start`)
|
|
95
|
+
return { sessionName, started: false, success: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const promptFile = path.join(os.tmpdir(), `minion-meeting-prompt-${sessionName}.txt`)
|
|
99
|
+
const mcpConfigFile = path.join(os.tmpdir(), `minion-meeting-mcp-${sessionName}.json`)
|
|
100
|
+
const execScript = path.join(os.tmpdir(), `minion-meeting-exec-${sessionName}.sh`)
|
|
101
|
+
const mcpServerPath = path.join(__dirname, '..', 'core', 'meetings', 'meeting-mcp-server.js')
|
|
102
|
+
|
|
103
|
+
console.log(`[MeetingRunner] Starting meeting ${meetingId} (${title})`)
|
|
104
|
+
console.log(`[MeetingRunner] tmux session: ${sessionName}`)
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await logManager.ensureLogDir()
|
|
108
|
+
|
|
109
|
+
const prompt = buildMeetingKickoffPrompt({
|
|
110
|
+
meetingId,
|
|
111
|
+
title,
|
|
112
|
+
purpose,
|
|
113
|
+
host,
|
|
114
|
+
selfName: selfName || 'minion',
|
|
115
|
+
role: role || null,
|
|
116
|
+
})
|
|
117
|
+
await fs.writeFile(promptFile, prompt, 'utf-8')
|
|
118
|
+
|
|
119
|
+
// Use the minion's own config.HQ_URL — set correctly at provisioning time.
|
|
120
|
+
// For docker minions this is the docker-network internal URL
|
|
121
|
+
// (HQ_INTERNAL_URL=http://frontend-server:3000), which differs from
|
|
122
|
+
// browser-facing NEXT_PUBLIC_SITE_URL=http://localhost:3000. Falling back
|
|
123
|
+
// to the URL pushed by HQ only if local config is missing.
|
|
124
|
+
const effectiveHqUrl = config.HQ_URL || hqUrl
|
|
125
|
+
if (!effectiveHqUrl) {
|
|
126
|
+
throw new Error('No HQ_URL configured (neither minion config nor HQ push)')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// MCP config injecting our env into the meeting server child process
|
|
130
|
+
const mcpConfig = {
|
|
131
|
+
mcpServers: {
|
|
132
|
+
meeting: {
|
|
133
|
+
command: 'node',
|
|
134
|
+
args: [mcpServerPath],
|
|
135
|
+
env: {
|
|
136
|
+
HQ_URL: effectiveHqUrl,
|
|
137
|
+
API_TOKEN: config.API_TOKEN,
|
|
138
|
+
MINION_MEETING_ID: meetingId,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
await fs.writeFile(mcpConfigFile, JSON.stringify(mcpConfig, null, 2), 'utf-8')
|
|
144
|
+
|
|
145
|
+
const primary = getActivePrimary()
|
|
146
|
+
if (!primary || primary.name !== 'claude') {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'Meeting participation currently requires the claude primary LLM (MCP support). ' +
|
|
149
|
+
`Active primary: ${primary ? primary.name : 'none'}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Build invocation directly to inject --mcp-config (not supported by
|
|
154
|
+
// buildShellInvocation, which is shared across plugins).
|
|
155
|
+
//
|
|
156
|
+
// --allowedTools: Claude Code defaults to prompting the user for every MCP
|
|
157
|
+
// tool call, which hangs in -p mode (no interactive UI). Pre-authorize the
|
|
158
|
+
// meeting MCP tools so claude can run the participation loop without
|
|
159
|
+
// approval prompts. We do NOT bypass permissions globally — built-in tools
|
|
160
|
+
// (Bash, Read, Write etc.) still go through the user's normal allow list.
|
|
161
|
+
const { resolveBinary } = require('../core/llm-plugins/lib/spawn-helper')
|
|
162
|
+
const claudeBin = resolveBinary('claude', config.HOME_DIR)
|
|
163
|
+
const allowedTools = [
|
|
164
|
+
'mcp__meeting__meeting_wait_for_next_message',
|
|
165
|
+
'mcp__meeting__meeting_speak',
|
|
166
|
+
'mcp__meeting__meeting_get_state',
|
|
167
|
+
'mcp__meeting__meeting_leave',
|
|
168
|
+
].join(',')
|
|
169
|
+
const llmCommand = `${claudeBin} -p --mcp-config "${mcpConfigFile}" --allowedTools "${allowedTools}" < "${promptFile}"`
|
|
170
|
+
|
|
171
|
+
await fs.writeFile(
|
|
172
|
+
execScript,
|
|
173
|
+
`#!/bin/bash\nexport HQ_URL="${effectiveHqUrl}"\nexport API_TOKEN="${config.API_TOKEN}"\nexport MINION_MEETING_ID="${meetingId}"\n${llmCommand}\n`,
|
|
174
|
+
'utf-8',
|
|
175
|
+
)
|
|
176
|
+
await execAsync(`chmod +x "${execScript}"`)
|
|
177
|
+
|
|
178
|
+
await execAsync(`tmux new-session -d -s "${sessionName}" -x 200 -y 50`, {
|
|
179
|
+
cwd: config.HOME_DIR,
|
|
180
|
+
})
|
|
181
|
+
await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
|
|
182
|
+
|
|
183
|
+
const logFile = path.join(
|
|
184
|
+
logManager.LOG_DIR || path.join(config.HOME_DIR, '.minion', 'logs'),
|
|
185
|
+
`meeting-${meetingId}.log`,
|
|
186
|
+
)
|
|
187
|
+
try {
|
|
188
|
+
await execAsync(`tmux pipe-pane -o -t "${sessionName}" "cat >> ${logFile}"`)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`[MeetingRunner] pipe-pane failed: ${err.message}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await execAsync(`tmux send-keys -t "${sessionName}" "bash ${execScript}" Enter`)
|
|
194
|
+
|
|
195
|
+
runningTasks.add({
|
|
196
|
+
type: 'meeting',
|
|
197
|
+
session_name: sessionName,
|
|
198
|
+
meeting_id: meetingId,
|
|
199
|
+
started_at: new Date().toISOString(),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
console.log(`[MeetingRunner] Meeting ${meetingId} session started`)
|
|
203
|
+
return { sessionName, started: true, success: true }
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`[MeetingRunner] Meeting ${meetingId} failed to start: ${err.message}`)
|
|
206
|
+
return { sessionName, started: false, success: false, error: err.message }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
runMeeting,
|
|
212
|
+
killMeetingSession,
|
|
213
|
+
listMeetingSessions,
|
|
214
|
+
generateSessionName,
|
|
215
|
+
}
|
package/linux/server.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* TODOs: GET/POST /api/todos, GET/PUT/DELETE /api/todos/:id
|
|
24
24
|
* Emails: POST /api/email/inbox, GET /api/email/inbox, GET/PUT/DELETE /api/email/inbox/:id
|
|
25
25
|
* Sudoers: GET /api/sudoers
|
|
26
|
+
* Meetings (exp): POST /api/meetings/invitations, DELETE /api/meetings/:id, GET /api/meetings/active
|
|
26
27
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
27
28
|
*/
|
|
28
29
|
|
|
@@ -94,6 +95,8 @@ const { fileRoutes } = require('./routes/files')
|
|
|
94
95
|
const { directiveRoutes } = require('./routes/directives')
|
|
95
96
|
const { chatRoutes, runQuickLlmCall } = require('./routes/chat')
|
|
96
97
|
const { configRoutes } = require('./routes/config')
|
|
98
|
+
const { meetingRoutes } = require('../core/routes/meetings')
|
|
99
|
+
const meetingRunner = require('./meeting-runner')
|
|
97
100
|
|
|
98
101
|
// Validate configuration before starting
|
|
99
102
|
validate()
|
|
@@ -308,6 +311,9 @@ async function registerAllRoutes(app) {
|
|
|
308
311
|
await app.register(directiveRoutes)
|
|
309
312
|
await app.register(chatRoutes)
|
|
310
313
|
await app.register(configRoutes)
|
|
314
|
+
|
|
315
|
+
// Experimental: meetings (shared route + linux runner)
|
|
316
|
+
await app.register(meetingRoutes, { meetingRunner })
|
|
311
317
|
}
|
|
312
318
|
|
|
313
319
|
// Start server
|
package/package.json
CHANGED
package/rules/core.md
CHANGED
|
@@ -277,6 +277,38 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
|
|
|
277
277
|
|
|
278
278
|
詳細な API 仕様は `~/.minion/docs/api-reference.md` の「Project Sprints」「Project Tasks」セクションを参照。
|
|
279
279
|
|
|
280
|
+
## Meeting Participation (ミーティング参加, experimental, v3.59.0〜)
|
|
281
|
+
|
|
282
|
+
HQ からミーティング招待 (`POST /api/meetings/invitations`) を受け取ると、専用 tmux セッション `mt-{meetingId8}` で claude が起動し、`meeting` MCP サーバー経由で会議に参加する。
|
|
283
|
+
|
|
284
|
+
**基本ループ:**
|
|
285
|
+
1. `meeting_get_state` で参加者・目的・タイトルを把握
|
|
286
|
+
2. `meeting_wait_for_next_message` で次の発言を long-poll (最大 25秒)
|
|
287
|
+
3. 受信メッセージを評価:
|
|
288
|
+
- 自分の役割・コンテキストに関連するか?
|
|
289
|
+
- 直前の発言で他者が同趣旨を発言していないか?
|
|
290
|
+
- 自分宛 (mention) か、自分が応答すべき話題か?
|
|
291
|
+
4. 応答すべきなら `meeting_speak` で **1-3 文程度** の発言を送信
|
|
292
|
+
5. 戻り値が `{ meeting_ended: true }` になったら `meeting_leave` を呼んで終了
|
|
293
|
+
|
|
294
|
+
**振る舞いのルール:**
|
|
295
|
+
- 発言は短く保つ (会議でだらだら話さない)
|
|
296
|
+
- 他のミニオンの発言と重複しない (既に同じ趣旨を誰かが言っていたら追従しない)
|
|
297
|
+
- 議論を前進させる発言を優先 (賛否・代替案・質問・確認)
|
|
298
|
+
- 既存タスク・スプリント状態を踏まえて発言する
|
|
299
|
+
- **PMロールで参加する場合は議事進行と成果物作成も担う**:
|
|
300
|
+
- 議論が発散したら論点を整理して提示
|
|
301
|
+
- ミーティング終了前に趣旨に応じた成果物を作成:
|
|
302
|
+
- スプリント計画 → `hq sprint create` + `hq task create` (連結)
|
|
303
|
+
- 振り返り → `hq note create`
|
|
304
|
+
- ブロッカー解決 → 関連スレッドを resolve + note作成
|
|
305
|
+
- 趣旨が曖昧な場合は終了前に「何を成果物として残すか」を参加者に確認
|
|
306
|
+
- 結論が出なかった話題は thread や project task に転記してフォロー
|
|
307
|
+
|
|
308
|
+
セッション復帰機構はまだ未実装 (claude/tmux が落ちた場合は再起動しない)。
|
|
309
|
+
|
|
310
|
+
API詳細は `~/.minion/docs/api-reference.md` の「Meeting Rooms」セクションを参照。
|
|
311
|
+
|
|
280
312
|
## Todo運用ルール
|
|
281
313
|
|
|
282
314
|
チャット中のタスクは `/api/todos` に登録して進捗管理すること。圧縮(context compaction)を跨いでも作業を完遂するための仕組みがある。
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting Runner (Windows / WSL)
|
|
3
|
+
*
|
|
4
|
+
* Spawns a dedicated tmux session inside WSL per meeting invitation.
|
|
5
|
+
* Mirrors linux/meeting-runner.js but routes commands through WSL since
|
|
6
|
+
* Windows native tmux is not available.
|
|
7
|
+
*
|
|
8
|
+
* Session naming: `mt-{meetingId.slice(0,8)}`
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { exec } = require('child_process')
|
|
12
|
+
const { promisify } = require('util')
|
|
13
|
+
const fs = require('fs').promises
|
|
14
|
+
const os = require('os')
|
|
15
|
+
const path = require('path')
|
|
16
|
+
const execAsync = promisify(exec)
|
|
17
|
+
|
|
18
|
+
const { config } = require('../core/config')
|
|
19
|
+
const runningTasks = require('../core/lib/running-tasks')
|
|
20
|
+
const logManager = require('../core/lib/log-manager')
|
|
21
|
+
const { getActivePrimary } = require('../core/llm-plugins/lib/active')
|
|
22
|
+
const { buildMeetingKickoffPrompt } = require('../core/meetings/meeting-prompt')
|
|
23
|
+
|
|
24
|
+
function generateSessionName(meetingId) {
|
|
25
|
+
if (!meetingId) throw new Error('meetingId is required')
|
|
26
|
+
return `mt-${String(meetingId).substring(0, 8)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function wslExec(cmd) {
|
|
30
|
+
return execAsync(`wsl bash -lc ${JSON.stringify(cmd)}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function tmuxHasSession(sessionName) {
|
|
34
|
+
try {
|
|
35
|
+
await wslExec(`tmux has-session -t "${sessionName}" 2>/dev/null`)
|
|
36
|
+
return true
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function listMeetingSessions() {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await wslExec(`tmux ls -F '#S' 2>/dev/null`)
|
|
45
|
+
return stdout
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter((s) => s.startsWith('mt-'))
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function killMeetingSession(meetingId) {
|
|
55
|
+
const sessionName = generateSessionName(meetingId)
|
|
56
|
+
try {
|
|
57
|
+
await wslExec(`tmux kill-session -t "${sessionName}" 2>/dev/null`)
|
|
58
|
+
runningTasks.remove(sessionName)
|
|
59
|
+
return true
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runMeeting({ meetingId, title, purpose, host, hqUrl, selfName, role }) {
|
|
66
|
+
if (!meetingId) {
|
|
67
|
+
return { sessionName: null, started: false, success: false, error: 'meetingId is required' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sessionName = generateSessionName(meetingId)
|
|
71
|
+
|
|
72
|
+
if (await tmuxHasSession(sessionName)) {
|
|
73
|
+
console.log(`[MeetingRunner-win] tmux session ${sessionName} already exists, skipping start`)
|
|
74
|
+
return { sessionName, started: false, success: true }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tmpdir = os.tmpdir()
|
|
78
|
+
const promptFile = path.join(tmpdir, `minion-meeting-prompt-${sessionName}.txt`)
|
|
79
|
+
const mcpConfigFile = path.join(tmpdir, `minion-meeting-mcp-${sessionName}.json`)
|
|
80
|
+
const execScript = path.join(tmpdir, `minion-meeting-exec-${sessionName}.sh`)
|
|
81
|
+
const mcpServerPath = path.join(__dirname, '..', 'core', 'meetings', 'meeting-mcp-server.js')
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await logManager.ensureLogDir()
|
|
85
|
+
|
|
86
|
+
const prompt = buildMeetingKickoffPrompt({
|
|
87
|
+
meetingId,
|
|
88
|
+
title,
|
|
89
|
+
purpose,
|
|
90
|
+
host,
|
|
91
|
+
selfName: selfName || 'minion',
|
|
92
|
+
role: role || null,
|
|
93
|
+
})
|
|
94
|
+
await fs.writeFile(promptFile, prompt, 'utf-8')
|
|
95
|
+
|
|
96
|
+
// Use the minion's own config.HQ_URL when available. HQ-pushed hq_url is
|
|
97
|
+
// only a fallback because the minion already has the correct URL set at
|
|
98
|
+
// provisioning time.
|
|
99
|
+
const effectiveHqUrl = config.HQ_URL || hqUrl
|
|
100
|
+
if (!effectiveHqUrl) {
|
|
101
|
+
throw new Error('No HQ_URL configured (neither minion config nor HQ push)')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mcpConfig = {
|
|
105
|
+
mcpServers: {
|
|
106
|
+
meeting: {
|
|
107
|
+
command: 'node',
|
|
108
|
+
args: [mcpServerPath],
|
|
109
|
+
env: {
|
|
110
|
+
HQ_URL: effectiveHqUrl,
|
|
111
|
+
API_TOKEN: config.API_TOKEN,
|
|
112
|
+
MINION_MEETING_ID: meetingId,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
await fs.writeFile(mcpConfigFile, JSON.stringify(mcpConfig, null, 2), 'utf-8')
|
|
118
|
+
|
|
119
|
+
const primary = getActivePrimary()
|
|
120
|
+
if (!primary || primary.name !== 'claude') {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'Meeting participation currently requires the claude primary LLM (MCP support).',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// See linux/meeting-runner.js for why --allowedTools is needed (-p mode
|
|
127
|
+
// hangs on MCP permission prompts without this).
|
|
128
|
+
const allowedTools = [
|
|
129
|
+
'mcp__meeting__meeting_wait_for_next_message',
|
|
130
|
+
'mcp__meeting__meeting_speak',
|
|
131
|
+
'mcp__meeting__meeting_get_state',
|
|
132
|
+
'mcp__meeting__meeting_leave',
|
|
133
|
+
].join(',')
|
|
134
|
+
|
|
135
|
+
await fs.writeFile(
|
|
136
|
+
execScript,
|
|
137
|
+
`#!/bin/bash\nexport HQ_URL="${effectiveHqUrl}"\nexport API_TOKEN="${config.API_TOKEN}"\nexport MINION_MEETING_ID="${meetingId}"\nclaude -p --mcp-config "${mcpConfigFile}" --allowedTools "${allowedTools}" < "${promptFile}"\n`,
|
|
138
|
+
'utf-8',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
await wslExec(`chmod +x "${execScript}"`)
|
|
142
|
+
await wslExec(`tmux new-session -d -s "${sessionName}" -x 200 -y 50`)
|
|
143
|
+
await wslExec(`tmux set-option -t "${sessionName}" remain-on-exit on`)
|
|
144
|
+
await wslExec(`tmux send-keys -t "${sessionName}" "bash ${execScript}" Enter`)
|
|
145
|
+
|
|
146
|
+
runningTasks.add({
|
|
147
|
+
type: 'meeting',
|
|
148
|
+
session_name: sessionName,
|
|
149
|
+
meeting_id: meetingId,
|
|
150
|
+
started_at: new Date().toISOString(),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return { sessionName, started: true, success: true }
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(`[MeetingRunner-win] Failed to start meeting ${meetingId}: ${err.message}`)
|
|
156
|
+
return { sessionName, started: false, success: false, error: err.message }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
runMeeting,
|
|
162
|
+
killMeetingSession,
|
|
163
|
+
listMeetingSessions,
|
|
164
|
+
generateSessionName,
|
|
165
|
+
}
|
package/win/minion-cli.ps1
CHANGED
|
@@ -17,6 +17,7 @@ $ApiToken = ''
|
|
|
17
17
|
$SetupTunnel = $false
|
|
18
18
|
$KeepData = $false
|
|
19
19
|
$Force = $false
|
|
20
|
+
$All = $false
|
|
20
21
|
|
|
21
22
|
$i = 0
|
|
22
23
|
while ($i -lt $args.Count) {
|
|
@@ -30,6 +31,7 @@ while ($i -lt $args.Count) {
|
|
|
30
31
|
'^--setup-tunnel$' { $SetupTunnel = $true }
|
|
31
32
|
'^--keep-data$' { $KeepData = $true }
|
|
32
33
|
'^--force$' { $Force = $true }
|
|
34
|
+
'^--all$' { $All = $true }
|
|
33
35
|
'^(-h|--help)$' { $Command = 'help' }
|
|
34
36
|
}
|
|
35
37
|
$i++
|
|
@@ -555,6 +557,70 @@ function Restart-MinionService {
|
|
|
555
557
|
Start-MinionService
|
|
556
558
|
}
|
|
557
559
|
|
|
560
|
+
function Restart-AllMinionServices {
|
|
561
|
+
# Restart every NSSM-managed minion service plus the user-session logon tasks.
|
|
562
|
+
# Use case: Windows Update or similar reboot left some services (typically
|
|
563
|
+
# cloudflared, which is DEMAND_START) not running. The plain `restart`
|
|
564
|
+
# command only touches minion-agent, so this is the catch-all recovery path.
|
|
565
|
+
$services = @('minion-cloudflared', 'minion-websockify', 'minion-agent')
|
|
566
|
+
|
|
567
|
+
# Step 1: Ask minion-agent to shut down gracefully (sends offline heartbeat).
|
|
568
|
+
# Same pattern as Stop-MinionService — don't wait for exit, sc.exe will finish it.
|
|
569
|
+
$token = ''
|
|
570
|
+
if (Test-Path $EnvFile) {
|
|
571
|
+
$envVars = Read-EnvFile $EnvFile
|
|
572
|
+
if ($envVars['API_TOKEN']) { $token = $envVars['API_TOKEN'] }
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
$headers = @{}
|
|
576
|
+
if ($token) { $headers['Authorization'] = "Bearer $token" }
|
|
577
|
+
Invoke-RestMethod -Uri "$AgentUrl/api/shutdown" -Method POST `
|
|
578
|
+
-ContentType 'application/json' -Headers $headers -TimeoutSec 3 | Out-Null
|
|
579
|
+
} catch {
|
|
580
|
+
# Agent may already be down — sc.exe stop will handle it
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
# Step 2: Stop services (cloudflared first so tunnel teardown doesn't race
|
|
584
|
+
# with agent shutdown, websockify next, agent last).
|
|
585
|
+
foreach ($svc in $services) {
|
|
586
|
+
$state = Get-ServiceState $svc
|
|
587
|
+
if (-not $state) {
|
|
588
|
+
Write-Warn "$svc: not installed, skipping"
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
if ($state -ne 'STOPPED') {
|
|
592
|
+
Write-Host "Stopping $svc..."
|
|
593
|
+
sc.exe stop $svc 2>&1 | Out-Null
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
# Step 3: Wait for all services to reach STOPPED (up to 15 seconds total).
|
|
598
|
+
for ($i = 0; $i -lt 15; $i++) {
|
|
599
|
+
$stillRunning = $false
|
|
600
|
+
foreach ($svc in $services) {
|
|
601
|
+
$s = Get-ServiceState $svc
|
|
602
|
+
if ($s -and $s -ne 'STOPPED') { $stillRunning = $true; break }
|
|
603
|
+
}
|
|
604
|
+
if (-not $stillRunning) { break }
|
|
605
|
+
Start-Sleep -Seconds 1
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
# Step 4: Start services in reverse order (agent → websockify → cloudflared).
|
|
609
|
+
foreach ($svc in @('minion-agent', 'minion-websockify', 'minion-cloudflared')) {
|
|
610
|
+
$state = Get-ServiceState $svc
|
|
611
|
+
if (-not $state) { continue }
|
|
612
|
+
Write-Host "Starting $svc..."
|
|
613
|
+
sc.exe start $svc 2>&1 | Out-Null
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
# Step 5: Re-trigger user-session logon tasks. schtasks /Run is a no-op if
|
|
617
|
+
# the task is already running, so this is safe regardless of current state.
|
|
618
|
+
schtasks /Run /TN MinionVNC 2>$null | Out-Null
|
|
619
|
+
schtasks /Run /TN MinionWSL 2>$null | Out-Null
|
|
620
|
+
|
|
621
|
+
Write-Host "All minion services restarted"
|
|
622
|
+
}
|
|
623
|
+
|
|
558
624
|
# ============================================================
|
|
559
625
|
# Setup
|
|
560
626
|
# ============================================================
|
|
@@ -1785,7 +1851,9 @@ switch ($Command) {
|
|
|
1785
1851
|
'stop' {
|
|
1786
1852
|
if ($Force) { Stop-MinionServiceForce } else { Stop-MinionService }
|
|
1787
1853
|
}
|
|
1788
|
-
'restart' {
|
|
1854
|
+
'restart' {
|
|
1855
|
+
if ($All) { Restart-AllMinionServices } else { Restart-MinionService }
|
|
1856
|
+
}
|
|
1789
1857
|
'status' { Show-Status }
|
|
1790
1858
|
'health' { Show-Health }
|
|
1791
1859
|
'daemons' { Show-Daemons }
|
|
@@ -1806,6 +1874,7 @@ switch ($Command) {
|
|
|
1806
1874
|
Write-Host " stop Stop the minion-agent service (graceful)"
|
|
1807
1875
|
Write-Host " stop --force Force-stop all minion services & processes (admin required)"
|
|
1808
1876
|
Write-Host " restart Restart the minion-agent service"
|
|
1877
|
+
Write-Host " restart --all Restart all minion services (agent + websockify + cloudflared)"
|
|
1809
1878
|
Write-Host " status Show agent service status"
|
|
1810
1879
|
Write-Host " health Check agent health endpoint"
|
|
1811
1880
|
Write-Host " daemons Show all daemon service status"
|
|
@@ -1827,5 +1896,11 @@ switch ($Command) {
|
|
|
1827
1896
|
Write-Host " kill remaining helpers (tvnserver/websockify/cloudflared)"
|
|
1828
1897
|
Write-Host " and node.exe processes that lock package files."
|
|
1829
1898
|
Write-Host " Use when graceful stop fails (e.g. corrupted update)."
|
|
1899
|
+
Write-Host ""
|
|
1900
|
+
Write-Host "Restart options:"
|
|
1901
|
+
Write-Host " --all Restart minion-agent, minion-websockify, and"
|
|
1902
|
+
Write-Host " minion-cloudflared, then re-trigger MinionVNC/MinionWSL"
|
|
1903
|
+
Write-Host " logon tasks. Use after Windows Update or when only"
|
|
1904
|
+
Write-Host " some services came back online."
|
|
1830
1905
|
}
|
|
1831
1906
|
}
|
package/win/server.js
CHANGED
|
@@ -49,6 +49,8 @@ const { fileRoutes } = require('./routes/files')
|
|
|
49
49
|
const { directiveRoutes } = require('./routes/directives')
|
|
50
50
|
const { chatRoutes, runQuickLlmCall } = require('./routes/chat')
|
|
51
51
|
const { configRoutes } = require('./routes/config')
|
|
52
|
+
const { meetingRoutes } = require('../core/routes/meetings')
|
|
53
|
+
const meetingRunner = require('./meeting-runner')
|
|
52
54
|
|
|
53
55
|
// Compatible route modules (reused from Linux)
|
|
54
56
|
const { healthRoutes, setOffline } = require('../core/routes/health')
|
|
@@ -255,6 +257,9 @@ async function registerRoutes(app) {
|
|
|
255
257
|
await app.register(directiveRoutes)
|
|
256
258
|
await app.register(chatRoutes)
|
|
257
259
|
await app.register(configRoutes)
|
|
260
|
+
|
|
261
|
+
// Experimental: meetings (shared route + windows runner)
|
|
262
|
+
await app.register(meetingRoutes, { meetingRunner })
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
// Start server
|