@geekbeer/minion 3.43.0 → 3.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/config.js CHANGED
@@ -54,11 +54,13 @@ loadEnvFile()
54
54
  * Resolve the correct home directory for the minion user.
55
55
  * On Linux, supervisord environments may set HOME=/root incorrectly.
56
56
  * This function uses MINION_USER + getent passwd to find the correct home.
57
+ * On macOS, LaunchAgent always inherits the correct HOME from the user's session,
58
+ * and `getent` is not available — so we always fall through to os.homedir().
57
59
  * On Windows, os.homedir() is always correct (returns %USERPROFILE%).
58
60
  */
59
61
  function resolveHomeDir() {
60
62
  const minionUser = process.env.MINION_USER
61
- if (minionUser && process.platform !== 'win32') {
63
+ if (minionUser && process.platform === 'linux') {
62
64
  try {
63
65
  const entry = execSync(`getent passwd ${minionUser}`, { encoding: 'utf-8' }).trim()
64
66
  const home = entry.split(':')[5]
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Build the system-prompt injection block for a `board_task` chat session.
3
+ *
4
+ * Used by linux/routes/chat.js and win/routes/chat.js when the incoming
5
+ * /api/chat call carries `context.type === 'board_task'`. Centralized here
6
+ * so the two platform-specific chat handlers stay in sync — board-task
7
+ * sessions are autonomous, so the assistant must receive identical guidance
8
+ * regardless of OS.
9
+ */
10
+
11
+ function buildBoardTaskInjection(context) {
12
+ if (!context || context.type !== 'board_task' || !context.task) return null
13
+
14
+ const task = context.task
15
+ const ac = Array.isArray(task.acceptance_criteria) ? task.acceptance_criteria : []
16
+ const projectId = context.project_id || task.project_id
17
+ const lines = [
18
+ '[ボードタスク自動着手モード]',
19
+ 'このセッションはボードタスクの自律着手で起動されました。ユーザーからの追加指示を待たず、',
20
+ '受け入れ要件を満たすまで自走してください。',
21
+ '',
22
+ `タスク: [task:${task.id}] ${task.title}`,
23
+ `優先度: ${task.priority || 'normal'}` + (task.due_date ? ` / 期限: ${task.due_date}` : ''),
24
+ `所属プロジェクト: ${projectId}`,
25
+ ]
26
+ if (context.sprint && context.sprint.name) {
27
+ lines.push(`所属スプリント: ${context.sprint.name} (${context.sprint.id})`)
28
+ }
29
+ if (task.description) {
30
+ lines.push('', '## タスク説明', String(task.description))
31
+ }
32
+ lines.push('', '## 受け入れ要件 (Definition of Done)')
33
+ if (ac.length > 0) {
34
+ for (const item of ac) {
35
+ const mark = item.checked ? '[x]' : '[ ]'
36
+ lines.push(`- ${mark} ${item.text}`)
37
+ }
38
+ lines.push(
39
+ '',
40
+ '上記すべてにチェックが入った状態で完了とみなしてください。受け入れ要件のチェックを更新するには、',
41
+ `PATCH \$HQ_URL/api/minion/projects/${projectId}/tasks/${task.id} の body で acceptance_criteria を渡します:`,
42
+ '```json',
43
+ '{ "acceptance_criteria": [{"id":"<既存のID>","text":"...","checked":true}, ...] }',
44
+ '```',
45
+ '※ 既存のIDを保持したまま checked のみ更新すること。新規項目は id を省略してOK (サーバが付与)。',
46
+ )
47
+ } else {
48
+ lines.push(
49
+ '⚠️ acceptance_criteria が未設定のままアクティブ化されています (DoR違反)。ユーザーに要件を確認するか、',
50
+ '安全側で曖昧な解釈を避け、状況を Notes/Memory に記録してから review に出してください。',
51
+ )
52
+ }
53
+
54
+ if (context.project_context_content) {
55
+ lines.push(
56
+ '',
57
+ '## プロジェクトコンテキスト (project_contexts.content)',
58
+ String(context.project_context_content),
59
+ )
60
+ }
61
+
62
+ if (Array.isArray(context.members) && context.members.length > 0) {
63
+ lines.push('', '## プロジェクトメンバー')
64
+ for (const m of context.members) {
65
+ const role = m.role ? ` (${m.role})` : ''
66
+ const name = m.name || m.display_name || m.minion_id || m.user_id || '?'
67
+ lines.push(`- ${name}${role}`)
68
+ }
69
+ }
70
+
71
+ lines.push(
72
+ '',
73
+ '## 完了時の操作',
74
+ '受け入れ要件をすべて満たしたら、以下でタスクを `review` に遷移しユーザーに引き渡してください:',
75
+ '```bash',
76
+ `curl -X PATCH -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \\`,
77
+ ` -d '{"status":"review"}' \\`,
78
+ ` \$HQ_URL/api/minion/projects/${projectId}/tasks/${task.id}`,
79
+ '```',
80
+ '途中で人間の判断が必要になった場合は、status は `doing` のまま threads (`POST /api/threads`) でPMにエスカレーションするか、',
81
+ 'Notes/Memory に状況を記録してから review に出すこと。勝手に done に遷移しないこと。',
82
+ )
83
+
84
+ return lines.join('\n')
85
+ }
86
+
87
+ module.exports = { buildBoardTaskInjection }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Board Task Poller
3
+ *
4
+ * Polls HQ for board tasks the minion is assigned to in an active sprint,
5
+ * claims them idempotently, and dispatches the work to a platform-specific
6
+ * board-task-runner that starts a dedicated tmux session named `bt-{id8}`.
7
+ *
8
+ * Detection criteria (server-side filter on /api/minion/board-tasks/pending):
9
+ * - assignee_minion_id == self
10
+ * - status IN ('todo', 'doing')
11
+ * - sprint.status == 'active'
12
+ *
13
+ * Concurrency is shared with dag-step-poller via concurrency-manager so the
14
+ * minion never exceeds MAX_CONCURRENT autonomous jobs across both pollers.
15
+ *
16
+ * The runner is injected via setRunner() at server startup so this module
17
+ * stays platform-agnostic. Linux supplies linux/board-task-runner.js
18
+ * (direct tmux); Windows supplies win/board-task-runner.js (HTTP proxy to
19
+ * wsl-session-server which runs tmux inside WSL).
20
+ */
21
+
22
+ const { config, isHqConfigured } = require('../config')
23
+ const concurrency = require('./concurrency-manager')
24
+
25
+ // Polling interval: 30 seconds (matches dag-step-poller).
26
+ const POLL_INTERVAL_MS = 30_000
27
+
28
+ let polling = false
29
+ let pollTimer = null
30
+ let lastPollAt = null
31
+ let runner = null
32
+
33
+ /**
34
+ * Inject the platform-specific runner.
35
+ * Required interface:
36
+ * - runBoardTask({ task, contextData }): Promise<{ sessionName, started, success, error? }>
37
+ * - isBoardTaskRunning(taskId): Promise<boolean>
38
+ * - generateSessionName(taskId): string
39
+ */
40
+ function setRunner(impl) {
41
+ runner = impl
42
+ }
43
+
44
+ async function hqRequest(endpoint, options = {}) {
45
+ if (!isHqConfigured()) {
46
+ return { skipped: true, reason: 'HQ not configured' }
47
+ }
48
+ const url = `${config.HQ_URL}${endpoint}`
49
+ const resp = await fetch(url, {
50
+ ...options,
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'Authorization': `Bearer ${config.API_TOKEN}`,
54
+ ...(options.headers || {}),
55
+ },
56
+ })
57
+ const text = await resp.text()
58
+ let data
59
+ try {
60
+ data = text ? JSON.parse(text) : {}
61
+ } catch {
62
+ data = { raw: text }
63
+ }
64
+ if (!resp.ok) {
65
+ const err = new Error(data.error || `HQ ${endpoint} failed: ${resp.status}`)
66
+ err.statusCode = resp.status
67
+ err.body = data
68
+ throw err
69
+ }
70
+ return data
71
+ }
72
+
73
+ async function pollOnce() {
74
+ if (!isHqConfigured()) return
75
+ if (!runner) {
76
+ console.warn('[BoardTaskPoller] No runner injected, skipping poll')
77
+ return
78
+ }
79
+ if (polling) return
80
+
81
+ polling = true
82
+ try {
83
+ const data = await hqRequest('/api/minion/board-tasks/pending')
84
+ const tasks = data.tasks || []
85
+ if (tasks.length === 0) return
86
+
87
+ console.log(
88
+ `[BoardTaskPoller] Found ${tasks.length} pending board task(s), ` +
89
+ `active: ${concurrency.size()}/${concurrency.MAX_CONCURRENT}`,
90
+ )
91
+
92
+ for (const task of tasks) {
93
+ const key = `board-task:${task.id}`
94
+ if (concurrency.has(key)) continue
95
+
96
+ // Local-session check: if a tmux session for this task is already
97
+ // alive on this minion, the work is in progress — skip claiming and
98
+ // let the running session finish. This handles minion restarts and
99
+ // sprint carry-overs naturally.
100
+ try {
101
+ if (await runner.isBoardTaskRunning(task.id)) {
102
+ console.log(`[BoardTaskPoller] Task ${task.id} already running locally, skipping`)
103
+ continue
104
+ }
105
+ } catch (err) {
106
+ console.error(`[BoardTaskPoller] Local session check failed for ${task.id}: ${err.message}`)
107
+ }
108
+
109
+ if (!concurrency.acquire(key, 'board-task')) break
110
+
111
+ claimAndStart(task)
112
+ .catch(err => {
113
+ console.error(`[BoardTaskPoller] task ${task.id} error: ${err.message}`)
114
+ })
115
+ .finally(() => {
116
+ concurrency.release(key)
117
+ })
118
+ }
119
+ } catch (err) {
120
+ if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
121
+ console.log('[BoardTaskPoller] HQ unreachable, will retry next cycle')
122
+ } else {
123
+ console.error(`[BoardTaskPoller] Poll error: ${err.message}`)
124
+ }
125
+ } finally {
126
+ polling = false
127
+ lastPollAt = new Date().toISOString()
128
+ }
129
+ }
130
+
131
+ async function claimAndStart(task) {
132
+ // 1. Idempotent claim. 409 = sprint inactive / status moved off doing — skip.
133
+ let claimed
134
+ try {
135
+ claimed = await hqRequest(`/api/minion/board-tasks/${task.id}/claim`, { method: 'POST' })
136
+ } catch (err) {
137
+ if (err.statusCode === 409 || err.statusCode === 403) {
138
+ console.log(`[BoardTaskPoller] Task ${task.id} no longer claimable: ${err.message}`)
139
+ return
140
+ }
141
+ throw err
142
+ }
143
+ const claimedTask = claimed.task || task
144
+ if (claimed.was_already_doing) {
145
+ console.log(`[BoardTaskPoller] Task ${task.id} was already 'doing' (carry-over), starting runner`)
146
+ }
147
+
148
+ // 2. Best-effort fetch of project context + members to enrich the prompt.
149
+ const [projectContext, members] = await Promise.all([
150
+ hqRequest(`/api/minion/me/project/${claimedTask.project_id}/context`).catch(() => null),
151
+ hqRequest(`/api/minion/me/project/${claimedTask.project_id}/members`).catch(() => null),
152
+ ])
153
+
154
+ // 3. Hand off to the platform-specific runner.
155
+ const result = await runner.runBoardTask({
156
+ task: claimedTask,
157
+ contextData: {
158
+ sprint: task.sprint || null,
159
+ projectContextContent: projectContext?.content || null,
160
+ members: Array.isArray(members?.members) ? members.members : null,
161
+ },
162
+ })
163
+
164
+ if (result.success) {
165
+ console.log(
166
+ `[BoardTaskPoller] Task ${claimedTask.id} runner finished ` +
167
+ `(session=${result.sessionName}, started=${result.started})`,
168
+ )
169
+ } else {
170
+ console.error(
171
+ `[BoardTaskPoller] Task ${claimedTask.id} runner failed: ${result.error || 'unknown'}`,
172
+ )
173
+ }
174
+ }
175
+
176
+ function start() {
177
+ if (!isHqConfigured()) {
178
+ console.log('[BoardTaskPoller] HQ not configured, board task poller disabled')
179
+ return
180
+ }
181
+ if (!runner) {
182
+ console.warn('[BoardTaskPoller] start() called before setRunner(), poller will be inert')
183
+ }
184
+ // Stagger start slightly after dag-step-poller (which starts at +7s).
185
+ setTimeout(() => pollOnce(), 11_000)
186
+ pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
187
+ console.log(
188
+ `[BoardTaskPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s, ` +
189
+ `max concurrent: ${concurrency.MAX_CONCURRENT} shared)`,
190
+ )
191
+ }
192
+
193
+ function stop() {
194
+ if (pollTimer) {
195
+ clearInterval(pollTimer)
196
+ pollTimer = null
197
+ console.log('[BoardTaskPoller] Stopped')
198
+ }
199
+ }
200
+
201
+ function getStatus() {
202
+ return {
203
+ running: pollTimer !== null,
204
+ last_poll_at: lastPollAt,
205
+ concurrency: concurrency.snapshot(),
206
+ runner_attached: !!runner,
207
+ }
208
+ }
209
+
210
+ module.exports = { start, stop, pollOnce, getStatus, setRunner }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Concurrency Manager
3
+ *
4
+ * Shared semaphore for poller-driven autonomous work (DAG nodes + board tasks).
5
+ * Each poller registers an active job with a unique key so the global
6
+ * concurrency cap is enforced even when multiple pollers run side-by-side.
7
+ *
8
+ * The cap matches the pre-existing dag-step-poller value (2) — keeping a
9
+ * single global limit avoids accidental over-subscription when the minion
10
+ * has both DAG and board work simultaneously.
11
+ */
12
+
13
+ const MAX_CONCURRENT = 2
14
+
15
+ const active = new Map() // key -> { kind, startedAt }
16
+
17
+ function size() {
18
+ return active.size
19
+ }
20
+
21
+ function has(key) {
22
+ return active.has(key)
23
+ }
24
+
25
+ function isFull() {
26
+ return active.size >= MAX_CONCURRENT
27
+ }
28
+
29
+ function acquire(key, kind) {
30
+ if (active.has(key)) return false
31
+ if (active.size >= MAX_CONCURRENT) return false
32
+ active.set(key, { kind, startedAt: new Date().toISOString() })
33
+ return true
34
+ }
35
+
36
+ function release(key) {
37
+ active.delete(key)
38
+ }
39
+
40
+ function snapshot() {
41
+ return {
42
+ active: active.size,
43
+ max_concurrent: MAX_CONCURRENT,
44
+ jobs: Array.from(active.entries()).map(([key, meta]) => ({ key, ...meta })),
45
+ }
46
+ }
47
+
48
+ module.exports = {
49
+ MAX_CONCURRENT,
50
+ size,
51
+ has,
52
+ isFull,
53
+ acquire,
54
+ release,
55
+ snapshot,
56
+ }
@@ -12,19 +12,22 @@
12
12
  const { config, isHqConfigured } = require('../config')
13
13
  const api = require('../api')
14
14
  const variableStore = require('../stores/variable-store')
15
+ const concurrency = require('./concurrency-manager')
15
16
 
16
17
  // Polling interval: 30 seconds (matches step-poller)
17
18
  const POLL_INTERVAL_MS = 30_000
18
19
 
19
- // Maximum concurrent DAG nodes this minion can execute
20
- const MAX_CONCURRENT = 2
20
+ // Concurrency is enforced by the shared concurrency-manager (MAX_CONCURRENT=2).
21
+ // This poller shares the cap with board-task-poller so the minion never runs
22
+ // more than MAX_CONCURRENT autonomous jobs at once.
21
23
 
22
24
  // Prevent concurrent poll cycles
23
25
  let polling = false
24
26
  let pollTimer = null
25
27
  let lastPollAt = null
26
28
 
27
- // Track active node executions: nodeExecId -> Promise
29
+ // Track active node executions: nodeExecId -> Promise (kept for legacy callers
30
+ // of getStatus(); the authoritative cap is in concurrency-manager).
28
31
  const activeNodes = new Map()
29
32
 
30
33
  /**
@@ -67,21 +70,24 @@ async function pollOnce() {
67
70
 
68
71
  if (!data.nodes || data.nodes.length === 0) return
69
72
 
70
- console.log(`[DagPoller] Found ${data.nodes.length} pending node(s), active: ${activeNodes.size}/${MAX_CONCURRENT}`)
73
+ console.log(`[DagPoller] Found ${data.nodes.length} pending node(s), active: ${concurrency.size()}/${concurrency.MAX_CONCURRENT}`)
71
74
 
72
75
  for (const node of data.nodes) {
73
- if (activeNodes.size >= MAX_CONCURRENT) break
74
- if (activeNodes.has(node.node_execution_id)) continue
76
+ const key = `dag:${node.node_execution_id}`
77
+ if (concurrency.has(key)) continue
78
+ if (!concurrency.acquire(key, 'dag')) break
75
79
 
80
+ activeNodes.set(node.node_execution_id, true)
76
81
  const promise = executeNode(node)
77
82
  .catch(err => {
78
83
  console.error(`[DagPoller] Node ${node.node_id} execution error: ${err.message}`)
79
84
  })
80
85
  .finally(() => {
86
+ concurrency.release(key)
81
87
  activeNodes.delete(node.node_execution_id)
82
88
  })
83
-
84
- activeNodes.set(node.node_execution_id, promise)
89
+ // eslint-disable-next-line no-unused-expressions
90
+ promise
85
91
  }
86
92
  } catch (err) {
87
93
  if (err.message?.includes('fetch failed') || err.message?.includes('ECONNREFUSED')) {
@@ -483,7 +489,7 @@ function start() {
483
489
 
484
490
  setTimeout(() => pollOnce(), 7000) // Slightly delayed after step-poller
485
491
  pollTimer = setInterval(() => pollOnce(), POLL_INTERVAL_MS)
486
- console.log(`[DagPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s, max concurrent: ${MAX_CONCURRENT})`)
492
+ console.log(`[DagPoller] Started (polling every ${POLL_INTERVAL_MS / 1000}s, max concurrent: ${concurrency.MAX_CONCURRENT} shared)`)
487
493
  }
488
494
 
489
495
  function stop() {
@@ -499,7 +505,7 @@ function getStatus() {
499
505
  running: pollTimer !== null,
500
506
  last_poll_at: lastPollAt,
501
507
  active_nodes: activeNodes.size,
502
- max_concurrent: MAX_CONCURRENT,
508
+ max_concurrent: concurrency.MAX_CONCURRENT,
503
509
  }
504
510
  }
505
511
 
@@ -29,11 +29,25 @@ async function runEndOfDay({ workspaceId, runQuickLlmCall, clearSession = false
29
29
  throw new Error('workspaceId is required for end-of-day processing')
30
30
  }
31
31
 
32
- const today = new Date().toISOString().split('T')[0]
33
- const session = await chatStore.load(workspaceId)
32
+ // Use the system's local timezone so that `today` and the message-window
33
+ // boundaries match the cron schedule (which fires in local TZ).
34
+ const now = new Date()
35
+ const yyyy = now.getFullYear()
36
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
37
+ const dd = String(now.getDate()).padStart(2, '0')
38
+ const today = `${yyyy}-${mm}-${dd}`
39
+ const dayStart = new Date(yyyy, now.getMonth(), now.getDate()).getTime()
40
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000
34
41
 
35
- // No conversation record a stub so the idle day is still logged.
36
- if (!session || session.messages.length === 0) {
42
+ const session = await chatStore.load(workspaceId)
43
+ const todayMessages = (session?.messages || []).filter(
44
+ m => typeof m.timestamp === 'number' && m.timestamp >= dayStart && m.timestamp < dayEnd
45
+ )
46
+
47
+ // No conversation today — record a stub so the idle day is still logged.
48
+ // (Past messages may remain in the session; they are intentionally excluded
49
+ // from today's summary.)
50
+ if (todayMessages.length === 0) {
37
51
  const ws = workspaceId ? workspaceStore.getById(workspaceId) : null
38
52
  const wsLabel = ws ? `${ws.name}` : (workspaceId ? `workspace:${workspaceId}` : '未所属')
39
53
  const stub = `# ${today} (${wsLabel})\n\n本日、このワークスペースでの会話はありませんでした。`
@@ -42,8 +56,8 @@ async function runEndOfDay({ workspaceId, runQuickLlmCall, clearSession = false
42
56
  return { daily_log: today, memory_entries_added: 0, had_conversation: false }
43
57
  }
44
58
 
45
- // Build conversation text for summarization (last 50 messages)
46
- const messages = session.messages.slice(-50)
59
+ // Build conversation text for summarization (last 50 messages of today)
60
+ const messages = todayMessages.slice(-50)
47
61
  const conversationText = messages
48
62
  .map(m => `${m.role === 'user' ? 'ユーザー' : 'アシスタント'}: ${m.content.substring(0, 500)}`)
49
63
  .join('\n\n')
@@ -11,19 +11,21 @@ const path = require('path')
11
11
  const fs = require('fs')
12
12
 
13
13
  const IS_WINDOWS = process.platform === 'win32'
14
+ const IS_MAC = process.platform === 'darwin'
14
15
  const PATH_SEPARATOR = path.delimiter // ';' on Windows, ':' on Unix
15
16
  const TEMP_DIR = os.tmpdir()
16
17
 
17
18
  /**
18
19
  * Resolve the data directory for minion agent persistent files.
19
20
  * Windows: %USERPROFILE%\.minion (matches minion-cli.ps1 / start-agent.ps1)
21
+ * macOS: ~/.minion (LaunchAgent runs as user, cannot write to /opt)
20
22
  * Linux: /opt/minion-agent (existing behavior)
21
23
  */
22
24
  function resolveDataDir() {
23
- if (IS_WINDOWS) {
24
- // Use ~/.minion to match minion-cli.ps1 and start-agent.ps1.
25
- // All Windows-specific code (CLI, process-manager, server.js) uses ~/.minion
26
- // as the canonical data directory, so core modules must align with it.
25
+ if (IS_WINDOWS || IS_MAC) {
26
+ // Windows: matches minion-cli.ps1 / start-agent.ps1
27
+ // macOS: LaunchAgent context agent runs as the dedicated `minion` user,
28
+ // so persistent state lives under that user's home.
27
29
  return path.join(os.homedir(), '.minion')
28
30
  }
29
31
  return '/opt/minion-agent'
@@ -39,21 +41,38 @@ const LOG_DIR = path.join(DATA_DIR, 'logs')
39
41
  * @returns {string} Extended PATH string
40
42
  */
41
43
  function buildExtendedPath(homeDir) {
42
- const additionalPaths = IS_WINDOWS
43
- ? [
44
- path.join(homeDir, '.local', 'bin'),
45
- path.join(homeDir, '.npm-global'),
46
- path.join(homeDir, '.claude', 'bin'),
47
- path.join(homeDir, 'AppData', 'Roaming', 'npm'),
48
- path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs'),
49
- ]
50
- : [
51
- path.join(homeDir, 'bin'),
52
- path.join(homeDir, '.npm-global', 'bin'),
53
- path.join(homeDir, '.local', 'bin'),
54
- path.join(homeDir, '.claude', 'bin'),
55
- '/usr/local/bin',
56
- ]
44
+ let additionalPaths
45
+ if (IS_WINDOWS) {
46
+ additionalPaths = [
47
+ path.join(homeDir, '.local', 'bin'),
48
+ path.join(homeDir, '.npm-global'),
49
+ path.join(homeDir, '.claude', 'bin'),
50
+ path.join(homeDir, 'AppData', 'Roaming', 'npm'),
51
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs'),
52
+ ]
53
+ } else if (IS_MAC) {
54
+ additionalPaths = [
55
+ // Homebrew (Apple Silicon)
56
+ '/opt/homebrew/bin',
57
+ '/opt/homebrew/sbin',
58
+ // Homebrew (Intel)
59
+ '/usr/local/bin',
60
+ '/usr/local/sbin',
61
+ // User-local
62
+ path.join(homeDir, 'bin'),
63
+ path.join(homeDir, '.npm-global', 'bin'),
64
+ path.join(homeDir, '.local', 'bin'),
65
+ path.join(homeDir, '.claude', 'bin'),
66
+ ]
67
+ } else {
68
+ additionalPaths = [
69
+ path.join(homeDir, 'bin'),
70
+ path.join(homeDir, '.npm-global', 'bin'),
71
+ path.join(homeDir, '.local', 'bin'),
72
+ path.join(homeDir, '.claude', 'bin'),
73
+ '/usr/local/bin',
74
+ ]
75
+ }
57
76
 
58
77
  const currentPath = process.env.PATH || ''
59
78
  return [...additionalPaths, currentPath].join(PATH_SEPARATOR)
@@ -99,6 +118,7 @@ function resolveEnvFilePath(homeDir) {
99
118
 
100
119
  module.exports = {
101
120
  IS_WINDOWS,
121
+ IS_MAC,
102
122
  PATH_SEPARATOR,
103
123
  TEMP_DIR,
104
124
  DATA_DIR,
@@ -9,6 +9,7 @@
9
9
 
10
10
  const stepPoller = require('../lib/step-poller')
11
11
  const dagStepPoller = require('../lib/dag-step-poller')
12
+ const boardTaskPoller = require('../lib/board-task-poller')
12
13
  const revisionWatcher = require('../lib/revision-watcher')
13
14
  const threadWatcher = require('../lib/thread-watcher')
14
15
  const reflectionScheduler = require('../lib/reflection-scheduler')
@@ -24,6 +25,7 @@ async function daemonRoutes(fastify, opts) {
24
25
  daemons: {
25
26
  step_poller: stepPoller.getStatus(),
26
27
  dag_step_poller: dagStepPoller.getStatus(),
28
+ board_task_poller: boardTaskPoller.getStatus(),
27
29
  revision_watcher: revisionWatcher.getStatus(),
28
30
  thread_watcher: threadWatcher.getStatus(),
29
31
  reflection_scheduler: reflectionScheduler.getStatus(),
@@ -18,7 +18,7 @@ const { execSync } = require('child_process')
18
18
  const { config, isHqConfigured } = require('../config')
19
19
  const { version } = require('../../package.json')
20
20
  const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
21
- const { IS_WINDOWS } = require('../lib/platform')
21
+ const { IS_WINDOWS, IS_MAC } = require('../lib/platform')
22
22
  const { getStatus } = require('./health')
23
23
 
24
24
  /**
@@ -62,6 +62,14 @@ function isPortListening(port) {
62
62
  stdio: 'pipe',
63
63
  })
64
64
  return out.includes('LISTENING')
65
+ } else if (IS_MAC) {
66
+ // macOS: ss does not exist; use lsof (canonical on macOS)
67
+ const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null`, {
68
+ encoding: 'utf-8',
69
+ timeout: 5000,
70
+ stdio: 'pipe',
71
+ })
72
+ return out.trim().length > 0
65
73
  } else {
66
74
  const out = execSync(`ss -tlnp 2>/dev/null | grep ":${port} " || netstat -tlnp 2>/dev/null | grep ":${port} "`, {
67
75
  encoding: 'utf-8',
@@ -159,6 +167,23 @@ function checkVnc() {
159
167
  }
160
168
  }
161
169
 
170
+ if (IS_MAC) {
171
+ // macOS: native Screen Sharing (screensharingd) + Node-based vnc-auth-proxy
172
+ // (replaces websockify as of v3.47.8). The proxy listens on 6080 and
173
+ // performs RFB auth against screensharingd:5900 using a local password.
174
+ const screensharingd = isProcessRunning('screensharingd') || isProcessRunning('ARDAgent')
175
+ const vncBackend = isPortListening(5900)
176
+ const vncProxy = isPortListening(6080)
177
+ return {
178
+ running: vncBackend && vncProxy,
179
+ details: [
180
+ `screensharingd: ${screensharingd ? 'running' : 'NOT RUNNING (enable: System Settings -> Sharing -> Screen Sharing)'}`,
181
+ `Screen Sharing (:5900): ${vncBackend ? 'listening' : 'NOT LISTENING'}`,
182
+ `vnc-auth-proxy (:6080): ${vncProxy ? 'listening' : 'NOT LISTENING'}`,
183
+ ].join(', '),
184
+ }
185
+ }
186
+
162
187
  // Linux: Xvfb + VNC server (x0vncserver or x11vnc) + websockify (noVNC)
163
188
  const xvfb = isProcessRunning('Xvfb')
164
189
  const x0vnc = isProcessRunning('x0vncserver') || isProcessRunning('X0tigervnc')
@@ -288,7 +313,7 @@ async function diagnoseRoutes(fastify) {
288
313
  return {
289
314
  summary: allOk ? 'ALL OK' : `${okCount}/${totalCount} checks passed`,
290
315
  version,
291
- platform: IS_WINDOWS ? 'windows' : 'linux',
316
+ platform: IS_WINDOWS ? 'windows' : IS_MAC ? 'darwin' : 'linux',
292
317
  timestamp: new Date().toISOString(),
293
318
  current_task: currentTask,
294
319
  checks,