@geekbeer/minion 3.58.0 → 3.59.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 }
@@ -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 に公開される。直接叩く必要はない。
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.58.0",
3
+ "version": "3.59.2",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -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/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