@geekbeer/minion 3.42.3 → 3.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -803,12 +803,29 @@ POST body:
803
803
  "priority": "normal", // low|normal|high|urgent (default: normal)
804
804
  "milestone_id": null,
805
805
  "parent_task_id": null, // 指定時は子タスクとして作成。親が既に子の場合 400
806
+ "sprint_id": null, // スプリントに含める場合のみ指定
806
807
  "assignee_minion_id": null,
807
808
  "assignee_user_id": null, // assignee_minion_id と同時指定不可
808
- "due_date": null // YYYY-MM-DD
809
+ "due_date": null, // YYYY-MM-DD
810
+ "acceptance_criteria": null // [{id?, text, checked?}] または string[] (idは省略時サーバが付与)
809
811
  }
810
812
  ```
811
813
 
814
+ **`acceptance_criteria` の形式:**
815
+ - `null` = 未設定
816
+ - `[{id: uuid, text: string, checked: boolean}, ...]` または `["text", "text", ...]`
817
+ - 既存項目の checked を更新する場合は **id を保持して** PATCH すること(idを省くと新規追加扱い)
818
+ - 配列を渡すとサーバ側で全置換される。既存項目を残したい場合は GET → 部分更新 → PATCH の手順を踏む
819
+
820
+ **スプリント自動着手 (Definition of Ready):**
821
+ - ボードタスクは `assignee_minion_id == self && status IN ('todo', 'doing') && sprint.status == 'active'` のとき、
822
+ ミニオンの `board-task-poller` が自動検知し `doing` へ遷移して **専用 tmux セッション (`bt-{taskId8}`) で自走** を起動する (v3.46.0〜)。
823
+ - `doing` 状態のタスクも対象に含むのは前スプリントからの持ち越しを救済するため。同名 tmux セッションが既に生きていれば再起動せずスキップ (二重実行防止)。
824
+ - スプリント開始(`active`遷移)には全タスクで以下が必要:
825
+ - `assignee_minion_id` が設定されている
826
+ - `acceptance_criteria` が1件以上ある
827
+ - 自動着手したミニオンは acceptance_criteria を満たしたら `status: 'review'` に遷移する。`done` は基本的に人間の承認後。
828
+
812
829
  ミニオン経由で作成したタスクには `created_by_minion_id` に自身のIDが自動設定される。
813
830
 
814
831
  PATCH body: 同じフィールドを任意で部分指定。**`status` を変更すると `status_changed_at` がサーバ側で自動更新される**(stalled 検出に使われる)。手動で `status_changed_at` を渡しても無視される。`assignee_minion_id` を set するともう一方の `assignee_user_id` は自動 null、その逆も同じ。
@@ -825,6 +842,61 @@ reorder body:
825
842
 
826
843
  両 neighbor が null の場合は列の先頭/初期値で挿入される。
827
844
 
845
+ #### `[task:UUID]` チケットタグ (HQチャット内専用)
846
+
847
+ ユーザーがHQチャットでチケットを参照する際、`[task:UUID]` 形式のタグを
848
+ メッセージに埋め込むことができる。HQプロキシがタグを検出してタスク詳細を
849
+ 解決し、チャットリクエストの `referenced_tasks` フィールドに同梱して
850
+ ミニオンに送信する。ミニオンは受信時にプロンプト先頭へ「参照チケット」
851
+ ブロックとして注入する(履歴には保存されない)。
852
+
853
+ ユーザーメッセージ例:
854
+ ```
855
+ [task:550e8400-e29b-41d4-a716-446655440000] このチケットの進捗を教えて
856
+ ```
857
+
858
+ Claudeへの注入例:
859
+ ```
860
+ [参照チケット — ユーザーがメッセージ内で `[task:UUID]` 形式で参照しているHQボード上のタスク]
861
+ - [task:550e8400-...] チケット名 (status: doing, priority: high) / 説明...
862
+ 詳細/更新: GET|PATCH $HQ_URL/api/minion/projects/<projectId>/tasks/<taskId>
863
+
864
+ (ユーザーメッセージ本文)
865
+ ```
866
+
867
+ 応答内でユーザーへのチケット言及にも同タグを使ってよい(HQ側でチップに描画される)。
868
+
869
+ ### Project Sprints (Agile自動着手)
870
+
871
+ スプリントはプロジェクトスコープのエンティティで、`planned -> active -> completed` の状態遷移を持つ。
872
+ **1プロジェクトにつき active スプリントは1つだけ** (DB側の partial unique index で強制)。
873
+
874
+ スプリントが `active` のとき、含まれるタスクのうち以下を満たすものはミニオンの `board-task-poller` (30秒間隔) が
875
+ 自動的に検知して着手する:
876
+ - `assignee_minion_id == 自分`
877
+ - `status IN ('todo', 'doing')`
878
+ - `sprint.status == 'active'`
879
+
880
+ 着手時の流れ (v3.46.0〜):
881
+ 1. ローカル tmux に `bt-{taskId8}` セッションが既にあればスキップ (二重実行防止 / 持ち越しの再起動防止)
882
+ 2. `POST /api/minion/board-tasks/:taskId/claim` で冪等に `doing` に遷移 (`todo→doing` または `doing→doing` でタイムスタンプ更新)
883
+ 3. **専用 tmux セッション (`bt-{taskId8}`) で `claude -p` を直接実行**。Linuxはホスト上のtmux、Windowsは WSL 内の tmux。
884
+ タスクのタイトル/受け入れ要件、project_contexts.content、メンバー一覧がプロンプトに自動注入される。
885
+ 4. ミニオンは acceptance_criteria を満たしたら `status: 'review'` に遷移してユーザーレビュー待ちにする。
886
+
887
+ セッション可視性:
888
+ - Linuxミニオン: `tmux ls | grep '^bt-'` でセッション一覧。WSターミナルからアタッチ可能。
889
+ - Windowsミニオン: `wsl tmux ls | grep '^bt-'` で確認 (or HQ ダッシュボードのターミナル一覧で `wsl-tmux` タイプとして表示)。
890
+
891
+ ミニオンが自分で参照する用途のためのスプリント API:
892
+
893
+ | Method | Endpoint | Description |
894
+ |--------|----------|-------------|
895
+ | GET | `/api/projects/:projectId/sprints` | スプリント一覧 (HQ認証 — ユーザーUI用) |
896
+
897
+ ミニオンからスプリントを直接作成・遷移させる必要は通常ない (PMロールのユーザーが管理する)。
898
+ 状況確認用には標準のタスクAPIで `?sprint_id=<uuid>` を使えば所属タスクが取得できる。
899
+
828
900
  ### Project Milestones
829
901
 
830
902
  | Method | Endpoint | Description |
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Board Task Runner (Linux)
3
+ *
4
+ * Starts a board task in a dedicated detached tmux session so the work is
5
+ * visible from the WS terminal (`tmux ls` shows `bt-{taskId8}`) and survives
6
+ * minion restarts. Unlike workflow/routine runners this does not run on a
7
+ * cron — it is invoked by board-task-poller after a successful claim.
8
+ *
9
+ * Session naming: `bt-{taskId.slice(0,8)}`
10
+ *
11
+ * Lifecycle:
12
+ * 1. If a `bt-*` tmux session already exists for this task, return early
13
+ * (carry-over case: minion restarted while the task was running, or a
14
+ * previous sprint left it alive). The existing session is the authority.
15
+ * 2. Otherwise spawn `claude -p < kickoff.txt` inside a new tmux session
16
+ * with remain-on-exit so the post-completion buffer stays inspectable.
17
+ * 3. Wait for an exit-code file to appear, then release.
18
+ */
19
+
20
+ const { exec } = require('child_process')
21
+ const { promisify } = require('util')
22
+ const fs = require('fs').promises
23
+ const os = require('os')
24
+ const path = require('path')
25
+ const execAsync = promisify(exec)
26
+
27
+ const { config } = require('../core/config')
28
+ const runningTasks = require('../core/lib/running-tasks')
29
+ const logManager = require('../core/lib/log-manager')
30
+ const { getActivePrimary } = require('../core/llm-plugins/lib/active')
31
+ const { buildBoardTaskInjection } = require('../core/lib/board-task-context')
32
+
33
+ // 4 hours — board tasks can be long-running but not infinite.
34
+ const TASK_TIMEOUT_MS = 4 * 60 * 60 * 1000
35
+ const POLL_INTERVAL_MS = 5_000
36
+
37
+ function generateSessionName(taskId) {
38
+ if (!taskId) throw new Error('taskId is required')
39
+ return `bt-${String(taskId).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 listBoardTaskSessions() {
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('bt-'))
58
+ } catch {
59
+ return []
60
+ }
61
+ }
62
+
63
+ function buildKickoffPrompt(task, contextData) {
64
+ const lines = [
65
+ `[ボードタスク自動着手] [task:${task.id}] ${task.title}`,
66
+ '',
67
+ 'このボードタスクが自動でアサインされたため、着手を開始してください。',
68
+ 'アクティブスプリント内でステータスを `doing` にしました。',
69
+ '',
70
+ '完了したら以下で `review` に遷移してユーザーにレビュー依頼してください:',
71
+ ` PATCH \$HQ_URL/api/minion/projects/${task.project_id}/tasks/${task.id}`,
72
+ ' Body: {"status": "review"}',
73
+ '',
74
+ ]
75
+ const injection = buildBoardTaskInjection({
76
+ type: 'board_task',
77
+ task,
78
+ project_id: task.project_id,
79
+ sprint: contextData.sprint || null,
80
+ project_context_content: contextData.projectContextContent || null,
81
+ members: contextData.members || null,
82
+ })
83
+ if (injection) {
84
+ lines.push(injection)
85
+ }
86
+ return lines.join('\n')
87
+ }
88
+
89
+ /**
90
+ * Start (or resume tracking of) a board task tmux session.
91
+ *
92
+ * @param {object} params
93
+ * @param {object} params.task - Claimed task object from HQ.
94
+ * @param {object} [params.contextData] - { sprint, projectContextContent, members }
95
+ * @returns {Promise<{ sessionName: string, started: boolean, success: boolean, error?: string }>}
96
+ */
97
+ async function runBoardTask({ task, contextData = {} }) {
98
+ if (!task || !task.id) {
99
+ return { sessionName: null, started: false, success: false, error: 'task is required' }
100
+ }
101
+
102
+ const sessionName = generateSessionName(task.id)
103
+ const homeDir = config.HOME_DIR
104
+
105
+ // Carry-over: a tmux session for this task is already alive. Don't relaunch.
106
+ if (await tmuxHasSession(sessionName)) {
107
+ console.log(`[BoardTaskRunner] tmux session ${sessionName} already exists, skipping start`)
108
+ runningTasks.add({
109
+ type: 'board_task',
110
+ session_name: sessionName,
111
+ board_task_id: task.id,
112
+ started_at: new Date().toISOString(),
113
+ })
114
+ return { sessionName, started: false, success: true }
115
+ }
116
+
117
+ const exitCodeFile = `/tmp/tmux-exit-${sessionName}`
118
+ const promptFile = path.join(os.tmpdir(), `minion-board-task-prompt-${sessionName}.txt`)
119
+ const execScript = path.join(os.tmpdir(), `minion-board-task-exec-${sessionName}.sh`)
120
+
121
+ console.log(`[BoardTaskRunner] Starting board task ${task.id} (${task.title})`)
122
+ console.log(`[BoardTaskRunner] tmux session: ${sessionName}`)
123
+
124
+ try {
125
+ await logManager.ensureLogDir()
126
+ await execAsync(`rm -f "${exitCodeFile}"`)
127
+
128
+ const prompt = buildKickoffPrompt(task, contextData)
129
+ await fs.writeFile(promptFile, prompt, 'utf-8')
130
+
131
+ const primary = getActivePrimary()
132
+ let llmCommand
133
+ if (primary && typeof primary.buildShellInvocation === 'function') {
134
+ llmCommand = primary.buildShellInvocation({ promptFile })
135
+ } else if (config.LLM_COMMAND) {
136
+ llmCommand = `${config.LLM_COMMAND} < ${promptFile}`
137
+ } else {
138
+ throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
139
+ }
140
+
141
+ await fs.writeFile(
142
+ execScript,
143
+ `#!/bin/bash\n${llmCommand}\necho $? > ${exitCodeFile}\n`,
144
+ 'utf-8',
145
+ )
146
+ await execAsync(`chmod +x "${execScript}"`)
147
+
148
+ await execAsync(
149
+ `tmux new-session -d -s "${sessionName}" -x 200 -y 50`,
150
+ { cwd: homeDir },
151
+ )
152
+ await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
153
+
154
+ // Pipe-pane to per-task log file (reuse log-manager naming under board-task-)
155
+ const logFile = path.join(logManager.LOG_DIR || path.join(homeDir, '.minion', 'logs'), `board-task-${task.id}.log`)
156
+ try {
157
+ await execAsync(`tmux pipe-pane -o -t "${sessionName}" "cat >> ${logFile}"`)
158
+ } catch (err) {
159
+ console.error(`[BoardTaskRunner] pipe-pane failed: ${err.message}`)
160
+ }
161
+
162
+ await execAsync(
163
+ `tmux send-keys -t "${sessionName}" "bash ${execScript}" Enter`,
164
+ )
165
+
166
+ runningTasks.add({
167
+ type: 'board_task',
168
+ session_name: sessionName,
169
+ board_task_id: task.id,
170
+ started_at: new Date().toISOString(),
171
+ })
172
+
173
+ const startTime = Date.now()
174
+ while (Date.now() - startTime < TASK_TIMEOUT_MS) {
175
+ try {
176
+ await fs.access(exitCodeFile)
177
+ break
178
+ } catch {
179
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
180
+ }
181
+ }
182
+
183
+ if (Date.now() - startTime >= TASK_TIMEOUT_MS) {
184
+ console.error(`[BoardTaskRunner] Task ${task.id} timed out after ${TASK_TIMEOUT_MS / 1000}s`)
185
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null || true`)
186
+ return { sessionName, started: true, success: false, error: 'Execution timeout' }
187
+ }
188
+
189
+ let exitCode = 0
190
+ try {
191
+ const { stdout } = await execAsync(`cat "${exitCodeFile}"`)
192
+ exitCode = parseInt(stdout.trim(), 10) || 0
193
+ await execAsync(`rm -f "${exitCodeFile}"`)
194
+ } catch {
195
+ // Treat as success if exit code can't be read but file existed
196
+ }
197
+
198
+ if (exitCode === 0) {
199
+ console.log(`[BoardTaskRunner] Task ${task.id} CLI completed`)
200
+ return { sessionName, started: true, success: true }
201
+ }
202
+ console.error(`[BoardTaskRunner] Task ${task.id} CLI exited with code ${exitCode}`)
203
+ return { sessionName, started: true, success: false, error: `Exit code: ${exitCode}` }
204
+ } catch (err) {
205
+ console.error(`[BoardTaskRunner] Task ${task.id} failed: ${err.message}`)
206
+ return { sessionName, started: true, success: false, error: err.message }
207
+ } finally {
208
+ runningTasks.remove(sessionName)
209
+ try { await fs.unlink(promptFile) } catch {}
210
+ try { await fs.unlink(execScript) } catch {}
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check if a board task is currently running (tmux session alive).
216
+ * Used by the poller to skip tasks already being worked on.
217
+ */
218
+ async function isBoardTaskRunning(taskId) {
219
+ return tmuxHasSession(generateSessionName(taskId))
220
+ }
221
+
222
+ module.exports = {
223
+ runBoardTask,
224
+ isBoardTaskRunning,
225
+ generateSessionName,
226
+ listBoardTaskSessions,
227
+ }
@@ -43,7 +43,7 @@ async function chatRoutes(fastify) {
43
43
  return { success: false, error: 'Unauthorized' }
44
44
  }
45
45
 
46
- const { message, session_id, context, workspace_id } = request.body || {}
46
+ const { message, session_id, context, workspace_id, referenced_tasks } = request.body || {}
47
47
 
48
48
  if (!message || typeof message !== 'string') {
49
49
  reply.code(400)
@@ -53,7 +53,9 @@ async function chatRoutes(fastify) {
53
53
  const workspaceId = workspace_id || null
54
54
 
55
55
  // Build prompt — add memory context on new sessions + page context + workspace
56
- const prompt = await buildContextPrefix(message, context, session_id, workspaceId)
56
+ // referenced_tasks is injected into the prompt only (not stored in history)
57
+ // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
58
+ const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
57
59
 
58
60
  // Store user message
59
61
  const currentSessionId = session_id || null
@@ -258,9 +260,27 @@ ${indexed}`
258
260
  * On new sessions (no session_id), injects minion memory + recent daily logs.
259
261
  * No conversation history injection — Claude CLI handles that via --resume.
260
262
  */
261
- async function buildContextPrefix(message, context, sessionId, workspaceId) {
263
+ async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks) {
262
264
  const parts = []
263
265
 
266
+ // Resolved [task:UUID] tags from HQ — surface ticket details so Claude can
267
+ // talk about them without first having to hit the API. Always include when
268
+ // present (regardless of session state), since tag references can appear in
269
+ // any turn.
270
+ if (Array.isArray(referencedTasks) && referencedTasks.length > 0) {
271
+ parts.push('[参照チケット — ユーザーがメッセージ内で `[task:UUID]` 形式で参照しているHQボード上のタスク]')
272
+ for (const t of referencedTasks) {
273
+ if (!t || !t.id) continue
274
+ const desc = t.description ? ` / ${String(t.description).slice(0, 200).replace(/\s+/g, ' ')}` : ''
275
+ const due = t.due_date ? ` / 期限: ${t.due_date}` : ''
276
+ parts.push(
277
+ `- [task:${t.id}] ${t.title || '(無題)'} (status: ${t.status || '?'}, priority: ${t.priority || '?'}${due})${desc}`,
278
+ ` 詳細/更新: GET|PATCH $HQ_URL/api/minion/projects/${t.project_id}/tasks/${t.id}`,
279
+ )
280
+ }
281
+ parts.push('')
282
+ }
283
+
264
284
  // Inject workspace context so Claude Code knows which workspace it's operating in
265
285
  if (workspaceId) {
266
286
  const workspaceStore = require('../../core/stores/workspace-store')
@@ -411,9 +431,9 @@ async function buildContextPrefix(message, context, sessionId, workspaceId) {
411
431
  ` hq fetch project ${context.projectId}`,
412
432
  ` hq fetch project-context ${context.projectId}`,
413
433
  `タスク・マイルストーン・健康度を扱う場合は以下のAPIを使えます (Bearer 認証必須):`,
414
- ` GET \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 一覧 (?milestone_id= ?status= ?priority=high,urgent ?assignee_minion_id= ?overdue=true ?q=<substring> 等で絞り込み可。q は日本語OKの部分一致)`,
415
- ` POST \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 作成 (body: title, description?, status?, priority?, milestone_id?, parent_task_id?, assignee_minion_id?, due_date?)`,
416
- ` PATCH \$HQ_URL/api/minion/projects/${context.projectId}/tasks/<id> # 更新 (status変更で status_changed_at がサーバ自動更新、priority 変更も可)`,
434
+ ` GET \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 一覧 (?milestone_id= ?sprint_id= ?status= ?priority=high,urgent ?assignee_minion_id= ?overdue=true ?q=<substring> 等で絞り込み可。q は日本語OKの部分一致)`,
435
+ ` POST \$HQ_URL/api/minion/projects/${context.projectId}/tasks # 作成 (body: title, description?, status?, priority?, milestone_id?, sprint_id?, parent_task_id?, assignee_minion_id?, due_date?, acceptance_criteria?)`,
436
+ ` PATCH \$HQ_URL/api/minion/projects/${context.projectId}/tasks/<id> # 更新 (status変更で status_changed_at がサーバ自動更新、acceptance_criteria/sprint_id も更新可。AC更新時は既存idを保持)`,
417
437
  ` GET \$HQ_URL/api/minion/projects/${context.projectId}/milestones # マイルストーン一覧`,
418
438
  ` GET \$HQ_URL/api/minion/projects/${context.projectId}/health # 健康度サマリ (overdue/stalled/マイルストーン進捗。progress_pct は leaf タスク基準)`,
419
439
  `タスクは5段階Kanban (backlog→todo→doing→review→done)、親子は2階層まで(孫不可)。priority は low|normal|high|urgent (可視化+フィルタ用)。親EPICに milestone_id を付ければ子タスクも進捗に自動反映される。詳細は ~/.minion/docs/api-reference.md の「Project Tasks」「Project Milestones」「Project Health」を参照。`,
package/linux/server.js CHANGED
@@ -50,6 +50,7 @@ let lastBeatAt = null
50
50
  // Linux-specific modules
51
51
  const workflowRunner = require('./workflow-runner')
52
52
  const routineRunner = require('./routine-runner')
53
+ const boardTaskRunner = require('./board-task-runner')
53
54
  const { cleanupTtyd, killStaleTtydProcesses } = require('./routes/terminal')
54
55
  const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
55
56
 
@@ -59,6 +60,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
59
60
  // Pull-model daemons (from core/)
60
61
  const stepPoller = require('../core/lib/step-poller')
61
62
  const dagStepPoller = require('../core/lib/dag-step-poller')
63
+ const boardTaskPoller = require('../core/lib/board-task-poller')
62
64
  const revisionWatcher = require('../core/lib/revision-watcher')
63
65
  const reflectionScheduler = require('../core/lib/reflection-scheduler')
64
66
  const threadWatcher = require('../core/lib/thread-watcher')
@@ -125,6 +127,7 @@ async function shutdown(signal) {
125
127
  // Stop pollers, runners, and scheduler
126
128
  stepPoller.stop()
127
129
  dagStepPoller.stop()
130
+ boardTaskPoller.stop()
128
131
  revisionWatcher.stop()
129
132
  reflectionScheduler.stop()
130
133
  threadWatcher.stop()
@@ -404,6 +407,8 @@ async function start() {
404
407
  // Start Pull-model daemons
405
408
  stepPoller.start()
406
409
  dagStepPoller.start()
410
+ boardTaskPoller.setRunner(boardTaskRunner)
411
+ boardTaskPoller.start()
407
412
  revisionWatcher.start()
408
413
  threadWatcher.start(runQuickLlmCall)
409
414
  } else {
package/mac/bin/hq ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ # macOS HQ API helper trampoline.
3
+ # The implementation is shared with Linux (pure bash + curl + jq).
4
+ exec "$(dirname "$0")/../../linux/bin/hq" "$@"
@@ -0,0 +1,4 @@
1
+ /**
2
+ * macOS board task runner — re-exports the Linux implementation.
3
+ */
4
+ module.exports = require('../linux/board-task-runner')
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Process manager detection and command building (macOS / launchd)
3
+ *
4
+ * macOS uses launchd for service management. The minion agent runs as a
5
+ * LaunchAgent under the dedicated `minion` user; restarts go through
6
+ * `launchctl kickstart -k gui/<uid>/<label>`.
7
+ */
8
+
9
+ const { execSync } = require('child_process')
10
+
11
+ // Use sudo only when not running as root (parity with linux/lib/process-manager.js).
12
+ // Most launchctl operations on `gui/<self-uid>/...` do NOT require sudo.
13
+ const SUDO = process.getuid && process.getuid() !== 0 ? 'sudo ' : ''
14
+
15
+ // Resolve once at module load. Used to build `gui/<uid>/<label>` targets.
16
+ function resolveUid() {
17
+ if (process.getuid) return process.getuid()
18
+ // Fallback (should never hit on macOS): query `id -u`
19
+ try {
20
+ return parseInt(execSync('id -u', { encoding: 'utf-8' }).trim(), 10)
21
+ } catch {
22
+ return 0
23
+ }
24
+ }
25
+
26
+ const UID = resolveUid()
27
+
28
+ /**
29
+ * Detect the process manager.
30
+ * On macOS, launchd is the only option (PID 1).
31
+ * Returns 'standalone' only if launchctl is somehow missing — which would
32
+ * indicate a broken environment, since launchctl is part of the OS.
33
+ * @returns {'launchd' | 'standalone'}
34
+ */
35
+ function detectProcessManager() {
36
+ try {
37
+ execSync('launchctl version', { stdio: 'ignore' })
38
+ return 'launchd'
39
+ } catch {
40
+ return 'standalone'
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Build allowed commands based on detected process manager.
46
+ * @param {string} procMgr - Process manager type
47
+ * @returns {Record<string, { description: string; command: string; deferred?: boolean }>}
48
+ */
49
+ function buildAllowedCommands(procMgr) {
50
+ const commands = {}
51
+
52
+ if (procMgr === 'launchd') {
53
+ const AGENT_TARGET = `gui/${UID}/com.geekbeer.minion`
54
+ const WEBSOCKIFY_TARGET = `gui/${UID}/com.geekbeer.websockify`
55
+ const TMUX_INIT_TARGET = `gui/${UID}/com.geekbeer.tmux-init`
56
+
57
+ commands['restart-agent'] = {
58
+ description: 'Restart the minion agent service',
59
+ command: `launchctl kickstart -k ${AGENT_TARGET}`,
60
+ deferred: true,
61
+ }
62
+ commands['update-agent'] = {
63
+ description: 'Update @geekbeer/minion to latest version and restart',
64
+ command: `${SUDO}npm install -g @geekbeer/minion@latest && launchctl kickstart -k ${AGENT_TARGET}`,
65
+ deferred: true,
66
+ }
67
+ commands['update-agent-dev'] = {
68
+ description: 'Update @geekbeer/minion from Verdaccio (dev) and restart',
69
+ command: `${SUDO}npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873 && launchctl kickstart -k ${AGENT_TARGET}`,
70
+ deferred: true,
71
+ }
72
+ commands['restart-display'] = {
73
+ description: 'Restart websockify (noVNC bridge); native Screen Sharing is system-managed',
74
+ command: `launchctl kickstart -k ${WEBSOCKIFY_TARGET}`,
75
+ }
76
+ commands['restart-all'] = {
77
+ description: 'Restart websockify and the agent',
78
+ command: `launchctl kickstart -k ${WEBSOCKIFY_TARGET} && launchctl kickstart -k ${AGENT_TARGET}`,
79
+ deferred: true,
80
+ }
81
+ commands['status-services'] = {
82
+ description: 'Print status of all minion LaunchAgents',
83
+ command: `launchctl print ${AGENT_TARGET} 2>/dev/null | head -40 ; echo "---" ; launchctl print ${WEBSOCKIFY_TARGET} 2>/dev/null | head -20 ; echo "---" ; launchctl print ${TMUX_INIT_TARGET} 2>/dev/null | head -20`,
84
+ }
85
+ } else {
86
+ // Standalone mode: limited commands (launchctl unavailable — unlikely on macOS)
87
+ commands['update-agent'] = {
88
+ description: 'Update @geekbeer/minion to latest version',
89
+ command: `${SUDO}npm install -g @geekbeer/minion@latest`,
90
+ }
91
+ commands['update-agent-dev'] = {
92
+ description: 'Update @geekbeer/minion from Verdaccio (dev)',
93
+ command: `${SUDO}npm install -g @geekbeer/minion@latest --registry http://verdaccio:4873`,
94
+ }
95
+ commands['status-services'] = {
96
+ description: 'Show agent process info',
97
+ command: 'echo "Process Manager: standalone (launchctl unavailable)" && echo "Agent PID: $$" && echo "Node version: $(node -v)" && echo "Uptime: $(ps -o etime= -p $$)"',
98
+ }
99
+ }
100
+
101
+ return commands
102
+ }
103
+
104
+ module.exports = {
105
+ SUDO,
106
+ UID,
107
+ detectProcessManager,
108
+ buildAllowedCommands,
109
+ }