@geekbeer/minion 4.3.4 → 4.4.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.
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Sync bundled Claude Code settings (permissions + hooks) into
5
+ * <homeDir>/.claude/settings.json. Shared by the Linux and Windows servers so
6
+ * both platforms get identical baseline permissions and the PreToolUse hooks.
7
+ *
8
+ * User overrides live in settings.local.json which Claude merges on top of the
9
+ * settings.json we write here, so re-syncing on every boot is safe.
10
+ */
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+
15
+ // packages/minion/core/lib/claude-settings-sync.js -> packages/minion
16
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..')
17
+
18
+ /**
19
+ * Copy bundled hook scripts to <claudeDir>/hooks/ and return the PreToolUse
20
+ * hook config block to embed in settings.json. Returns null when no hooks are
21
+ * bundled.
22
+ *
23
+ * Currently wires block-hq-navigation.js, which denies Playwright navigation to
24
+ * HQ (*.minion-agent.com) so the minion uses the API instead of scraping the
25
+ * dashboard.
26
+ */
27
+ function syncHookScripts(claudeDir, log) {
28
+ try {
29
+ const bundledHooksDir = path.join(PACKAGE_ROOT, 'settings', 'hooks')
30
+ if (!fs.existsSync(bundledHooksDir)) return null
31
+
32
+ const targetHooksDir = path.join(claudeDir, 'hooks')
33
+ if (!fs.existsSync(targetHooksDir)) {
34
+ fs.mkdirSync(targetHooksDir, { recursive: true })
35
+ }
36
+
37
+ const files = fs.readdirSync(bundledHooksDir).filter((f) => f.endsWith('.js'))
38
+ for (const file of files) {
39
+ fs.copyFileSync(
40
+ path.join(bundledHooksDir, file),
41
+ path.join(targetHooksDir, file),
42
+ )
43
+ }
44
+
45
+ const navHook = path.join(targetHooksDir, 'block-hq-navigation.js')
46
+ if (!fs.existsSync(navHook)) return null
47
+
48
+ log(`[Hooks] Synced ${files.length} hook script(s) to ~/.claude/hooks/`)
49
+ return {
50
+ PreToolUse: [
51
+ {
52
+ matcher: 'mcp__playwright__browser_navigate',
53
+ hooks: [{ type: 'command', command: `node ${navHook}` }],
54
+ },
55
+ ],
56
+ }
57
+ } catch (err) {
58
+ log(`[Hooks] Failed to sync hook scripts: ${err.message}`)
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Sync bundled permissions + hooks to <homeDir>/.claude/settings.json.
65
+ * @param {string} homeDir - the minion HOME (config.HOME_DIR)
66
+ * @param {(msg: string) => void} [log] - logger (defaults to console.log)
67
+ */
68
+ function syncClaudeSettings(homeDir, log = console.log) {
69
+ const bundledPath = path.join(PACKAGE_ROOT, 'settings', 'permissions.json')
70
+ const settingsDir = path.join(homeDir, '.claude')
71
+ const settingsPath = path.join(settingsDir, 'settings.json')
72
+
73
+ try {
74
+ if (!fs.existsSync(bundledPath)) return
75
+
76
+ const bundled = JSON.parse(fs.readFileSync(bundledPath, 'utf-8'))
77
+
78
+ let settings = {}
79
+ if (fs.existsSync(settingsPath)) {
80
+ try {
81
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
82
+ } catch {
83
+ log('[Permissions] existing settings.json invalid, overwriting')
84
+ }
85
+ }
86
+
87
+ settings.permissions = {
88
+ allow: bundled.allow || [],
89
+ deny: bundled.deny || [],
90
+ }
91
+
92
+ fs.mkdirSync(settingsDir, { recursive: true })
93
+
94
+ const hooks = syncHookScripts(settingsDir, log)
95
+ if (hooks) {
96
+ settings.hooks = hooks
97
+ }
98
+
99
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
100
+ log(`[Permissions] Synced: allow=${(bundled.allow || []).length}, deny=${(bundled.deny || []).length}`)
101
+ } catch (err) {
102
+ log(`[Permissions] Failed to sync permissions: ${err.message}`)
103
+ }
104
+ }
105
+
106
+ module.exports = { syncClaudeSettings }
@@ -41,6 +41,17 @@ curl -X POST http://localhost:8080/api/web/extract \
41
41
  | 「ログインしてダッシュボード操作」 | Playwright MCP |
42
42
  | 「フォームを送信」 | Playwright MCP |
43
43
  | 「複数ページ巡回して全件取得」 | `/api/web/extract` をループ呼び出し (各ページに対して) |
44
+ | **HQ (`*.minion-agent.com`) のノート/タスク/プロジェクト** | **HQ API(Playwright禁止)** |
45
+
46
+ ### HQ (`*.minion-agent.com`) のリンクは API で取得する
47
+
48
+ HQダッシュボードのページURLを Playwright MCP で開いてはならない(PreToolUse フック `block-hq-navigation.js` で拒否される)。受け取ったURL/タグは以下に変換する。チャットで参照された場合、本文はプロンプト先頭の「参照ノート」「参照チケット」ブロックに既に注入済みなので、まずそれを読む。
49
+
50
+ | 受け取った形 | 取得方法 |
51
+ |------|---------|
52
+ | `[note:UUID]` / `…/notes/:id` | `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`(または `…/projects/:pid/notes/:id`) |
53
+ | `[task:UUID]` / `…/tasks/:id` | `GET $HQ_URL/api/minion/projects/:pid/tasks/:id` |
54
+ | `…/projects/:id` | `GET $HQ_URL/api/minion/projects/:id` |
44
55
 
45
56
  ### キャッシュの確認・破棄 (debug)
46
57
 
@@ -44,7 +44,7 @@ async function chatRoutes(fastify) {
44
44
  return { success: false, error: 'Unauthorized' }
45
45
  }
46
46
 
47
- const { message, session_id, context, workspace_id, referenced_tasks } = request.body || {}
47
+ const { message, session_id, context, workspace_id, referenced_tasks, referenced_notes } = request.body || {}
48
48
 
49
49
  if (!message || typeof message !== 'string') {
50
50
  reply.code(400)
@@ -54,9 +54,10 @@ async function chatRoutes(fastify) {
54
54
  const workspaceId = workspace_id || null
55
55
 
56
56
  // Build prompt — add memory context on new sessions + page context + workspace
57
- // referenced_tasks is injected into the prompt only (not stored in history)
58
- // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
59
- const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
57
+ // referenced_tasks / referenced_notes are injected into the prompt only (not
58
+ // stored in history) so the user's chat log keeps just the [task:UUID] /
59
+ // [note:UUID] tag (or pasted URL), not a noisy dump.
60
+ const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks, referenced_notes)
60
61
 
61
62
  // Persist the user message BEFORE invoking the LLM so that crashes,
62
63
  // timeouts, or unparseable CLI output can't lose it. For new sessions we
@@ -267,7 +268,7 @@ ${indexed}`
267
268
  * On new sessions (no session_id), injects minion memory + recent daily logs.
268
269
  * No conversation history injection — Claude CLI handles that via --resume.
269
270
  */
270
- async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks) {
271
+ async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks, referencedNotes) {
271
272
  const parts = []
272
273
 
273
274
  // Resolved [task:UUID] tags from HQ — surface ticket details so Claude can
@@ -288,6 +289,29 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
288
289
  parts.push('')
289
290
  }
290
291
 
292
+ // Resolved [note:UUID] tags / pasted note URLs from HQ — surface the note body
293
+ // inline so Claude answers WITHOUT opening the HQ page with Playwright. The
294
+ // body is a snippet; the full note is one API call away.
295
+ if (Array.isArray(referencedNotes) && referencedNotes.length > 0) {
296
+ parts.push('[参照ノート — ユーザーがメッセージ内で `[note:UUID]` 形式またはURLで参照しているHQノート。Playwrightで開かず、全文が必要なら下記APIを使うこと]')
297
+ const NOTE_LIMIT = 2000
298
+ for (const n of referencedNotes) {
299
+ if (!n || !n.id) continue
300
+ const body = String(n.content || '').trim()
301
+ const truncated = body.length > NOTE_LIMIT
302
+ const snippet = truncated ? body.slice(0, NOTE_LIMIT) : body
303
+ parts.push(`- [note:${n.id}] ${n.title || '(無題)'} (status: ${n.status || '?'})`)
304
+ if (snippet) {
305
+ parts.push(' ```', ...snippet.split('\n').map((l) => ` ${l}`), ' ```')
306
+ }
307
+ parts.push(
308
+ ` 全文/更新: GET|PATCH $HQ_URL/api/minion/workspaces/${n.workspace_id}/notes/${n.id}` +
309
+ (truncated ? ' (本文は上記抜粋、全文はAPIで取得)' : ''),
310
+ )
311
+ }
312
+ parts.push('')
313
+ }
314
+
291
315
  // Inject workspace context so Claude Code knows which workspace it's operating in
292
316
  if (workspaceId) {
293
317
  const workspaceStore = require('../../core/stores/workspace-store')
@@ -388,6 +412,8 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
388
412
  '',
389
413
  'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
390
414
  '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
415
+ '',
416
+ 'HQ (`*.minion-agent.com`) のページURLは **Playwrightで開かずAPIで取得する**。ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
391
417
  ''
392
418
  )
393
419
  }
package/linux/server.js CHANGED
@@ -38,6 +38,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..')
38
38
  // Core shared modules
39
39
  const { config, validate, isHqConfigured } = require('../core/config')
40
40
  const { sendHeartbeat } = require('../core/api')
41
+ const { syncClaudeSettings } = require('../core/lib/claude-settings-sync')
41
42
  const { version } = require('../package.json')
42
43
 
43
44
  const workflowStore = require('../core/stores/workflow-store')
@@ -159,34 +160,11 @@ process.on('SIGTERM', () => shutdown('SIGTERM'))
159
160
  process.on('SIGINT', () => shutdown('SIGINT'))
160
161
 
161
162
  /**
162
- * Sync bundled permissions into ~/.claude/settings.json.
163
+ * Sync bundled permissions + PreToolUse hooks into ~/.claude/settings.json.
164
+ * Delegates to the shared core module so Linux and Windows stay in sync.
163
165
  */
164
166
  function syncPermissions() {
165
- const bundledPath = path.join(PACKAGE_ROOT, 'settings', 'permissions.json')
166
- const settingsDir = path.join(config.HOME_DIR, '.claude')
167
- const settingsPath = path.join(settingsDir, 'settings.json')
168
-
169
- try {
170
- if (!fs.existsSync(bundledPath)) return
171
-
172
- const bundled = JSON.parse(fs.readFileSync(bundledPath, 'utf-8'))
173
-
174
- let settings = {}
175
- if (fs.existsSync(settingsPath)) {
176
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
177
- }
178
-
179
- settings.permissions = {
180
- allow: bundled.allow || [],
181
- deny: bundled.deny || [],
182
- }
183
-
184
- fs.mkdirSync(settingsDir, { recursive: true })
185
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
186
- console.log(`[Permissions] Synced: allow=${bundled.allow.length}, deny=${bundled.deny.length}`)
187
- } catch (err) {
188
- console.error(`[Permissions] Failed to sync permissions: ${err.message}`)
189
- }
167
+ syncClaudeSettings(config.HOME_DIR, (msg) => console.log(msg))
190
168
  }
191
169
 
192
170
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "4.3.4",
3
+ "version": "4.4.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -47,6 +47,20 @@ Minion
47
47
  - **Workspace**: ミニオンは複数のワークスペースに所属でき、スキルやプロジェクトはワークスペース単位でスコープされる。チャットセッションもワークスペース別に分離される。所属ワークスペースはハートビートで自動同期され、`hq list workspaces` で確認できる。
48
48
  - ミニオンは複数プロジェクトに `pm`、`engineer`、`accountant` として参加できる。
49
49
 
50
+ ## HQ リソースへのリンクは API で取得する
51
+
52
+ `*.minion-agent.com`(HQダッシュボード)のページURLを **Playwright MCP で開いてはならない**。ほぼ全リソースがAPIで取得できる。受け取ったURL/タグは以下のAPIに変換して使うこと。
53
+
54
+ | 受け取った形 | 取得方法 |
55
+ |------|---------|
56
+ | `[note:UUID]` / `…/notes/:id` | `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`(プロジェクト紐づきなら `…/projects/:pid/notes/:id` も可) |
57
+ | `[task:UUID]` / `…/tasks/:id` | `GET $HQ_URL/api/minion/projects/:pid/tasks/:id` |
58
+ | `…/projects/:id` | `GET $HQ_URL/api/minion/projects/:id` |
59
+
60
+ - チャットでユーザーが `[note:UUID]` / `[task:UUID]` やノートURLを貼った場合、**本文はプロンプト先頭の「参照ノート」「参照チケット」ブロックに既に注入済み**。まずそれを読むこと。全文・最新版が要るときだけ上記APIを叩く。
61
+ - 認証なしのPlaywright閲覧はログイン画面や不完全なHTMLを掴むため結果が壊れる。HQリンクは必ずAPIで取得する。
62
+ - このルールは PreToolUse フックでも強制されており、`mcp__playwright__browser_navigate` で `*.minion-agent.com` を開こうとすると拒否される。
63
+
50
64
  ## Email
51
65
 
52
66
  各ミニオンには専用メールアドレス `m-{MINION_ID}@minion-agent.com` が割り当てられている。
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook: block Playwright navigation to HQ (*.minion-agent.com).
4
+ *
5
+ * The minion must read HQ resources (notes, tasks, projects) via the Agent/HQ
6
+ * API, not by scraping the dashboard with Playwright — unauthenticated browser
7
+ * navigation just lands on a login page or partial HTML and corrupts results.
8
+ * This hook is the enforcement layer behind the "HQ リソースへのリンクは API で
9
+ * 取得する" rule in core.md.
10
+ *
11
+ * Wired into ~/.claude/settings.json as:
12
+ * hooks.PreToolUse[] -> matcher "mcp__playwright__browser_navigate"
13
+ *
14
+ * Receives the tool call as JSON on stdin; denies via hookSpecificOutput when
15
+ * the target host matches HQ, allows everything else. Blocked attempts are
16
+ * appended to ~/.minion/logs/hq-nav-blocks.log for effectiveness measurement.
17
+ */
18
+ const fs = require('fs')
19
+ const os = require('os')
20
+ const path = require('path')
21
+
22
+ function readStdin() {
23
+ try {
24
+ return fs.readFileSync(0, 'utf8')
25
+ } catch {
26
+ return ''
27
+ }
28
+ }
29
+
30
+ /** Hosts considered "HQ". Overridable via HQ_NAV_BLOCK_HOSTS (comma-separated). */
31
+ function blockedHostSuffixes() {
32
+ const fromEnv = (process.env.HQ_NAV_BLOCK_HOSTS || '')
33
+ .split(',')
34
+ .map((s) => s.trim().toLowerCase())
35
+ .filter(Boolean)
36
+ const suffixes = new Set(['minion-agent.com', ...fromEnv])
37
+ // Also include the configured HQ host, in case it lives on a custom domain.
38
+ if (process.env.HQ_URL) {
39
+ try {
40
+ suffixes.add(new URL(process.env.HQ_URL).hostname.toLowerCase())
41
+ } catch {
42
+ /* ignore malformed HQ_URL */
43
+ }
44
+ }
45
+ return [...suffixes]
46
+ }
47
+
48
+ function isBlocked(rawUrl, suffixes) {
49
+ let host
50
+ try {
51
+ host = new URL(rawUrl).hostname.toLowerCase()
52
+ } catch {
53
+ return false
54
+ }
55
+ return suffixes.some((s) => host === s || host.endsWith(`.${s}`))
56
+ }
57
+
58
+ function logBlock(url) {
59
+ try {
60
+ const dir = path.join(os.homedir(), '.minion', 'logs')
61
+ fs.mkdirSync(dir, { recursive: true })
62
+ fs.appendFileSync(
63
+ path.join(dir, 'hq-nav-blocks.log'),
64
+ `${new Date().toISOString()}\t${url}\n`,
65
+ )
66
+ } catch {
67
+ /* best-effort logging only */
68
+ }
69
+ }
70
+
71
+ function allow() {
72
+ process.exit(0)
73
+ }
74
+
75
+ function deny(url) {
76
+ logBlock(url)
77
+ const reason =
78
+ `Playwright で ${url} を開くことは禁止されています。HQ (*.minion-agent.com) の` +
79
+ 'リソースはAPIで取得してください。例: ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、' +
80
+ 'タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。' +
81
+ 'チャットで参照されたノート/タスクの本文はプロンプト先頭の「参照ノート」「参照チケット」ブロックに既に注入されています。'
82
+ process.stdout.write(
83
+ JSON.stringify({
84
+ hookSpecificOutput: {
85
+ hookEventName: 'PreToolUse',
86
+ permissionDecision: 'deny',
87
+ permissionDecisionReason: reason,
88
+ },
89
+ }),
90
+ )
91
+ process.exit(0)
92
+ }
93
+
94
+ function main() {
95
+ let payload
96
+ try {
97
+ payload = JSON.parse(readStdin() || '{}')
98
+ } catch {
99
+ allow()
100
+ return
101
+ }
102
+ const url = payload?.tool_input?.url
103
+ if (typeof url !== 'string' || !url) {
104
+ allow()
105
+ return
106
+ }
107
+ if (isBlocked(url, blockedHostSuffixes())) {
108
+ deny(url)
109
+ } else {
110
+ allow()
111
+ }
112
+ }
113
+
114
+ main()
@@ -119,7 +119,7 @@ async function chatRoutes(fastify) {
119
119
  return { success: false, error: 'Unauthorized' }
120
120
  }
121
121
 
122
- const { message, session_id, context, wsl_mode, workspace_id, referenced_tasks } = request.body || {}
122
+ const { message, session_id, context, wsl_mode, workspace_id, referenced_tasks, referenced_notes } = request.body || {}
123
123
  if (!message || typeof message !== 'string') {
124
124
  reply.code(400)
125
125
  return { success: false, error: 'message is required' }
@@ -128,7 +128,7 @@ async function chatRoutes(fastify) {
128
128
  const workspaceId = workspace_id || null
129
129
  // referenced_tasks is injected into the prompt only (not stored in history)
130
130
  // so the user's chat log keeps just the [task:UUID] tag, not a noisy dump.
131
- const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks)
131
+ const prompt = await buildContextPrefix(message, context, session_id, workspaceId, referenced_tasks, referenced_notes)
132
132
  const currentSessionId = session_id || null
133
133
 
134
134
  // Persist the user message BEFORE invoking the LLM so that crashes,
@@ -336,7 +336,7 @@ ${indexed}`
336
336
  })
337
337
  }
338
338
 
339
- async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks) {
339
+ async function buildContextPrefix(message, context, sessionId, workspaceId, referencedTasks, referencedNotes) {
340
340
  const parts = []
341
341
 
342
342
  // Resolved [task:UUID] tags from HQ — surface ticket details so Claude can
@@ -355,6 +355,29 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
355
355
  parts.push('')
356
356
  }
357
357
 
358
+ // Resolved [note:UUID] tags / pasted note URLs from HQ — surface the note body
359
+ // inline so Claude answers WITHOUT opening the HQ page with Playwright. The
360
+ // body is a snippet; the full note is one API call away.
361
+ if (Array.isArray(referencedNotes) && referencedNotes.length > 0) {
362
+ parts.push('[参照ノート — ユーザーがメッセージ内で `[note:UUID]` 形式またはURLで参照しているHQノート。Playwrightで開かず、全文が必要なら下記APIを使うこと]')
363
+ const NOTE_LIMIT = 2000
364
+ for (const n of referencedNotes) {
365
+ if (!n || !n.id) continue
366
+ const body = String(n.content || '').trim()
367
+ const truncated = body.length > NOTE_LIMIT
368
+ const snippet = truncated ? body.slice(0, NOTE_LIMIT) : body
369
+ parts.push(`- [note:${n.id}] ${n.title || '(無題)'} (status: ${n.status || '?'})`)
370
+ if (snippet) {
371
+ parts.push(' ```', ...snippet.split('\n').map((l) => ` ${l}`), ' ```')
372
+ }
373
+ parts.push(
374
+ ` 全文/更新: GET|PATCH $HQ_URL/api/minion/workspaces/${n.workspace_id}/notes/${n.id}` +
375
+ (truncated ? ' (本文は上記抜粋、全文はAPIで取得)' : ''),
376
+ )
377
+ }
378
+ parts.push('')
379
+ }
380
+
358
381
  // Inject workspace context so Claude Code knows which workspace it's operating in
359
382
  if (workspaceId) {
360
383
  const workspaceStore = require('../../core/stores/workspace-store')
@@ -454,6 +477,8 @@ async function buildContextPrefix(message, context, sessionId, workspaceId, refe
454
477
  '',
455
478
  'Playwright MCP (`mcp__playwright__*`) は **ログイン・フォーム入力・複数画面の対話操作**が必要な場合のみ使用する。',
456
479
  '単純な閲覧・要約・一覧取得用途ではMCPを使わない。',
480
+ '',
481
+ 'HQ (`*.minion-agent.com`) のページURLは **Playwrightで開かずAPIで取得する**。ノートは `GET $HQ_URL/api/minion/workspaces/:wsId/notes/:id`、タスクは `GET $HQ_URL/api/minion/projects/:pid/tasks/:id`。チャットで参照されたノート/タスクの本文は上記「参照ノート」「参照チケット」ブロックに既に注入済み。',
457
482
  ''
458
483
  )
459
484
  }
package/win/server.js CHANGED
@@ -35,6 +35,7 @@ const { getConfigWarnings } = require('../core/lib/config-warnings')
35
35
 
36
36
  // Bundled skill deployment (version-gated, see core/lib/bundled-skills.js)
37
37
  const { syncBundledSkills } = require('../core/lib/bundled-skills')
38
+ const { syncClaudeSettings } = require('../core/lib/claude-settings-sync')
38
39
  const { getActiveSkillDirs } = require('../core/llm-plugins/lib/skill-dirs')
39
40
 
40
41
  // Pull-model daemons (from core/)
@@ -126,27 +127,11 @@ process.on('SIGTERM', () => shutdown('SIGTERM'))
126
127
  process.on('SIGINT', () => shutdown('SIGINT'))
127
128
 
128
129
  /**
129
- * Sync bundled permissions into ~/.claude/settings.json.
130
+ * Sync bundled permissions + PreToolUse hooks into ~/.claude/settings.json.
131
+ * Delegates to the shared core module so Linux and Windows stay in sync.
130
132
  */
131
133
  function syncPermissions() {
132
- const bundledPath = path.join(__dirname, '..', 'settings', 'permissions.json')
133
- const settingsDir = path.join(config.HOME_DIR, '.claude')
134
- const settingsPath = path.join(settingsDir, 'settings.json')
135
-
136
- try {
137
- if (!fs.existsSync(bundledPath)) return
138
- const bundled = JSON.parse(fs.readFileSync(bundledPath, 'utf-8'))
139
- let settings = {}
140
- if (fs.existsSync(settingsPath)) {
141
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
142
- }
143
- settings.permissions = { allow: bundled.allow || [], deny: bundled.deny || [] }
144
- fs.mkdirSync(settingsDir, { recursive: true })
145
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
146
- console.log(`[Permissions] Synced: allow=${bundled.allow.length}, deny=${bundled.deny.length}`)
147
- } catch (err) {
148
- console.error(`[Permissions] Failed to sync permissions: ${err.message}`)
149
- }
134
+ syncClaudeSettings(config.HOME_DIR, (msg) => console.log(msg))
150
135
  }
151
136
 
152
137
  /**