claude-brain 0.28.2 → 0.29.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.28.2
1
+ 0.29.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,23 +18,89 @@ export async function runServe() {
18
18
  // Auto-initialize home directory on first run
19
19
  ensureHomeDirectory()
20
20
 
21
- // --http-only: run HTTP server + services but no MCP stdio transport.
22
- // Used by auto-start background process (stdin is /dev/null, no Claude Code attached).
23
21
  const httpOnly = process.argv.includes('--http-only')
24
-
25
- // Determine instance role:
26
- // - httpOnly → always primary (background HTTP daemon)
27
- // - otherwise → secondary if primary is already running (Claude Code MCP session)
28
22
  const pidManager = new ServerPidManager()
29
- const existingPid = pidManager.getRunningPid()
30
- const isSecondaryInstance = !httpOnly && !!existingPid
23
+ const existingInstance = pidManager.getRunningInstance()
24
+
25
+ // ── Decision tree ──────────────────────────────────────────
26
+ // httpOnly + existing daemon → exit (duplicate daemon)
27
+ // httpOnly + no daemon → start as daemon (full services + HTTP + idle watchdog)
28
+ // !httpOnly + healthy daemon → run as proxy (thin MCP bridge, no services)
29
+ // !httpOnly + no daemon → start as daemon + MCP stdio (become daemon)
30
+
31
+ if (httpOnly && existingInstance) {
32
+ console.error(`[claude-brain] Primary instance already running (PID ${existingInstance.pid}). Exiting.`)
33
+ process.exit(0)
34
+ }
31
35
 
32
- if (!isSecondaryInstance) {
33
- // Primary instance: write PID file and register cleanup
34
- pidManager.writePidFile()
35
- pidManager.registerCleanupHandlers()
36
+ if (!httpOnly && existingInstance) {
37
+ // Check if existing daemon is healthy before committing to proxy mode
38
+ const healthy = await isDaemonHealthy(existingInstance.port)
39
+ if (healthy) {
40
+ return runAsProxy(existingInstance.port)
41
+ }
42
+ // Daemon not healthy — clean up stale PID and start as daemon
43
+ pidManager.cleanup()
36
44
  }
37
45
 
46
+ // ── Start as daemon ────────────────────────────────────────
47
+ return runAsDaemon(httpOnly, pidManager)
48
+ }
49
+
50
+ /** Check if the daemon at the given port is responsive and initialized */
51
+ async function isDaemonHealthy(port: number): Promise<boolean> {
52
+ try {
53
+ const res = await fetch(`http://localhost:${port}/api/health`, {
54
+ signal: AbortSignal.timeout(2000),
55
+ })
56
+ const json = await res.json() as any
57
+ return json.success === true && json.initialized === true
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ /** Run as thin MCP proxy — forward all MCP calls to daemon via HTTP */
64
+ async function runAsProxy(daemonPort: number) {
65
+ const config = await loadConfig()
66
+ const logger = createLogger(config.logLevel, config.logFilePath)
67
+ const mainLogger = createComponentLogger(logger, 'proxy')
68
+
69
+ mainLogger.info({ daemonPort }, 'Starting as MCP proxy → daemon')
70
+
71
+ const { McpProxyServer } = await import('@/server/mcp-proxy')
72
+
73
+ let isStopping = false
74
+ const proxy = new McpProxyServer({
75
+ daemonUrl: `http://localhost:${daemonPort}`,
76
+ serverName: config.serverName,
77
+ serverVersion: config.serverVersion,
78
+ onStdinClose: () => {
79
+ if (isStopping) return
80
+ isStopping = true
81
+ mainLogger.info('stdin closed — proxy exiting')
82
+ proxy.stop().finally(() => process.exit(0))
83
+ },
84
+ })
85
+
86
+ const stopProxy = async (signal: string) => {
87
+ if (isStopping) return
88
+ isStopping = true
89
+ mainLogger.info({ signal }, 'Proxy shutting down')
90
+ await proxy.stop()
91
+ process.exit(0)
92
+ }
93
+
94
+ process.on('SIGTERM', () => stopProxy('SIGTERM'))
95
+ process.on('SIGINT', () => stopProxy('SIGINT'))
96
+ process.on('SIGHUP', () => stopProxy('SIGHUP'))
97
+
98
+ await proxy.start()
99
+ mainLogger.info('MCP proxy ready — forwarding to daemon')
100
+ }
101
+
102
+ /** Run as full daemon with services, HTTP, and optionally MCP stdio */
103
+ async function runAsDaemon(httpOnly: boolean, pidManager: ServerPidManager) {
38
104
  // Auto-install Claude Code hooks (idempotent, non-fatal)
39
105
  try {
40
106
  const { installHooks } = await import('@/hooks/installer')
@@ -46,7 +112,7 @@ export async function runServe() {
46
112
  console.error(`[claude-brain] Hook auto-install skipped: ${error instanceof Error ? error.message : 'unknown error'}`)
47
113
  }
48
114
 
49
- // Auto-install shell auto-start if not already present (handles bun blocking postinstall)
115
+ // Auto-install shell auto-start if not already present
50
116
  try {
51
117
  const { isAutoStartInstalled, installAutoStart } = await import('@/cli/auto-start')
52
118
  if (!isAutoStartInstalled()) {
@@ -59,7 +125,7 @@ export async function runServe() {
59
125
  // Non-fatal — auto-start is a convenience feature
60
126
  }
61
127
 
62
- // Auto-install CLAUDE.md if not present (handles bun blocking postinstall)
128
+ // Auto-install CLAUDE.md if not present
63
129
  try {
64
130
  const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs')
65
131
  const { join, resolve, dirname } = await import('node:path')
@@ -143,6 +209,7 @@ export async function runServe() {
143
209
  mainLogger.error({ error: error.message }, 'MCP server error')
144
210
  },
145
211
  onRequest: (toolName, requestId) => {
212
+ pidManager.touchActivity()
146
213
  mainLogger.debug({ toolName, requestId }, 'Tool request received')
147
214
  },
148
215
  onResponse: (toolName, requestId, durationMs) => {
@@ -167,165 +234,193 @@ export async function runServe() {
167
234
  await shutdownServices()
168
235
  })
169
236
 
170
- // ── Primary instance only: HTTP server + background services ──
171
- // Secondary instances (when auto-start already running) only start MCP stdio
172
- if (!isSecondaryInstance) {
173
- // Clean up PID file during graceful shutdown
174
- cleanup.register(async () => {
175
- pidManager.cleanup()
176
- })
237
+ // ── Idempotent shutdown guard ────────────────────────────
238
+ let isShuttingDown = false
239
+ const shutdown = async (signal: string) => {
240
+ if (isShuttingDown) return
241
+ isShuttingDown = true
242
+ mainLogger.info({ signal }, 'Shutting down...')
243
+ await cleanup.cleanup()
244
+ process.exit(0)
245
+ }
177
246
 
178
- // Start HTTP API server alongside MCP server
179
- const { HttpApiServer } = await import('@/server/http-api')
180
- const httpServer = new HttpApiServer(config, logger)
247
+ // ── Primary daemon: HTTP server + background services ────
248
+ // Write PID file and register cleanup
249
+ pidManager.writePidFile(config.port)
250
+ pidManager.registerCleanupHandlers()
181
251
 
182
- cleanup.register(async () => {
183
- await httpServer.stop()
184
- })
252
+ cleanup.register(async () => {
253
+ pidManager.cleanup()
254
+ })
185
255
 
186
- // Phase 21: Use session tracker from services (promoted from local creation)
187
- {
188
- const { getSessionTracker } = await import('@/server/services')
189
- const sessionTracker = getSessionTracker()
190
- if (sessionTracker) {
191
- httpServer.setSessionTracker(sessionTracker)
192
- mainLogger.info('Session tracker wired to HTTP server')
193
- }
194
- }
256
+ // Start HTTP API server
257
+ const { HttpApiServer } = await import('@/server/http-api')
258
+ const httpServer = new HttpApiServer(config, logger)
195
259
 
196
- // Phase 28: Wire code intelligence to HTTP server
197
- {
198
- const { getCodeIndexer, getCodeQuery } = await import('@/server/services')
199
- const codeIndexer = getCodeIndexer()
200
- const codeQuery = getCodeQuery()
201
- if (codeIndexer && codeQuery) {
202
- httpServer.setCodeIntelligence(codeIndexer, codeQuery)
203
- mainLogger.info('Code intelligence wired to HTTP server')
204
- }
260
+ // Wire activity tracker to PID manager for idle watchdog
261
+ httpServer.setActivityTracker(() => pidManager.touchActivity())
262
+
263
+ cleanup.register(async () => {
264
+ await httpServer.stop()
265
+ })
266
+
267
+ // Phase 21: Use session tracker from services
268
+ {
269
+ const { getSessionTracker } = await import('@/server/services')
270
+ const sessionTracker = getSessionTracker()
271
+ if (sessionTracker) {
272
+ httpServer.setSessionTracker(sessionTracker)
273
+ mainLogger.info('Session tracker wired to HTTP server')
205
274
  }
275
+ }
206
276
 
207
- // Phase 29: Wire code linker to HTTP server
208
- {
209
- const { getCodeLinker } = await import('@/server/services')
210
- const codeLinker = getCodeLinker()
211
- if (codeLinker) {
212
- httpServer.setCodeLinker(codeLinker)
213
- mainLogger.info('Code linker wired to HTTP server')
214
- }
277
+ // Phase 28: Wire code intelligence to HTTP server
278
+ {
279
+ const { getCodeIndexer, getCodeQuery } = await import('@/server/services')
280
+ const codeIndexer = getCodeIndexer()
281
+ const codeQuery = getCodeQuery()
282
+ if (codeIndexer && codeQuery) {
283
+ httpServer.setCodeIntelligence(codeIndexer, codeQuery)
284
+ mainLogger.info('Code intelligence wired to HTTP server')
215
285
  }
286
+ }
216
287
 
217
- // Phase 30: Activity log pruning on startup + periodic cleanup
218
- {
219
- const memory = getMemoryService()
220
- if (memory?.isInitialized()) {
221
- try {
222
- const { startPeriodicPruning } = await import('@/memory/pruning')
223
- const db = memory.database.getDb()
224
- const stopPruning = startPeriodicPruning(db, logger, 30)
225
- cleanup.register(async () => {
226
- stopPruning()
227
- mainLogger.info('Activity log pruning stopped')
228
- })
229
- mainLogger.info('Activity log pruning initialized')
230
- } catch (error) {
231
- mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
232
- }
233
- }
288
+ // Phase 29: Wire code linker to HTTP server
289
+ {
290
+ const { getCodeLinker } = await import('@/server/services')
291
+ const codeLinker = getCodeLinker()
292
+ if (codeLinker) {
293
+ httpServer.setCodeLinker(codeLinker)
294
+ mainLogger.info('Code linker wired to HTTP server')
234
295
  }
296
+ }
235
297
 
236
- // Phase 30: Optional LLM compression
237
- if (config.compression?.enabled) {
298
+ // Phase 30: Activity log pruning on startup + periodic cleanup
299
+ {
300
+ const memory = getMemoryService()
301
+ if (memory?.isInitialized()) {
238
302
  try {
239
- const { ObservationCompressor } = await import('@/memory/compression')
240
- const { getBrainRouter } = await import('@/routing/router')
241
- const compressor = new ObservationCompressor(config.compression, logger)
242
- const router = getBrainRouter(logger)
243
- router.setCompressor(compressor)
244
- mainLogger.info({ provider: config.compression.provider, model: config.compression.model }, 'LLM compression enabled')
303
+ const { startPeriodicPruning } = await import('@/memory/pruning')
304
+ const db = memory.database.getDb()
305
+ const stopPruning = startPeriodicPruning(db, logger, 30)
306
+ cleanup.register(async () => {
307
+ stopPruning()
308
+ mainLogger.info('Activity log pruning stopped')
309
+ })
310
+ mainLogger.info('Activity log pruning initialized')
245
311
  } catch (error) {
246
- mainLogger.warn({ error }, 'Failed to initialize LLM compression')
312
+ mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
247
313
  }
248
314
  }
315
+ }
249
316
 
250
- // Phase 31: Auto-update checker
251
- let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
252
- if (config.autoUpdate?.enabled !== false) {
253
- try {
254
- const { AutoUpdater } = await import('@/server/auto-updater')
255
- autoUpdater = new AutoUpdater(
256
- {
257
- enabled: config.autoUpdate?.enabled ?? true,
258
- checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
259
- autoRestart: config.autoUpdate?.autoRestart ?? true,
260
- },
261
- mainLogger
262
- )
263
-
264
- // Check for updates on startup (non-blocking)
265
- autoUpdater.check().then(result => {
266
- if (result.updateAvailable && result.latestVersion) {
267
- mainLogger.info(
268
- { current: result.currentVersion, latest: result.latestVersion },
269
- 'Update available! Run: claude-brain update'
270
- )
271
- }
272
- }).catch(() => {})
273
-
274
- // Schedule periodic checks
275
- autoUpdater.schedulePeriodicCheck()
317
+ // Phase 30: Optional LLM compression
318
+ if (config.compression?.enabled) {
319
+ try {
320
+ const { ObservationCompressor } = await import('@/memory/compression')
321
+ const { getBrainRouter } = await import('@/routing/router')
322
+ const compressor = new ObservationCompressor(config.compression, logger)
323
+ const router = getBrainRouter(logger)
324
+ router.setCompressor(compressor)
325
+ mainLogger.info({ provider: config.compression.provider, model: config.compression.model }, 'LLM compression enabled')
326
+ } catch (error) {
327
+ mainLogger.warn({ error }, 'Failed to initialize LLM compression')
328
+ }
329
+ }
276
330
 
277
- cleanup.register(async () => {
278
- autoUpdater?.stopPeriodicCheck()
279
- mainLogger.info('Auto-updater stopped')
280
- })
331
+ // Phase 31: Auto-update checker
332
+ let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
333
+ if (config.autoUpdate?.enabled !== false) {
334
+ try {
335
+ const { AutoUpdater } = await import('@/server/auto-updater')
336
+ autoUpdater = new AutoUpdater(
337
+ {
338
+ enabled: config.autoUpdate?.enabled ?? true,
339
+ checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
340
+ autoRestart: config.autoUpdate?.autoRestart ?? true,
341
+ },
342
+ mainLogger
343
+ )
344
+
345
+ // Check for updates on startup (non-blocking)
346
+ autoUpdater.check().then(result => {
347
+ if (result.updateAvailable && result.latestVersion) {
348
+ mainLogger.info(
349
+ { current: result.currentVersion, latest: result.latestVersion },
350
+ 'Update available! Run: claude-brain update'
351
+ )
352
+ }
353
+ }).catch(() => {})
281
354
 
282
- mainLogger.info('Auto-updater initialized')
283
- } catch (error) {
284
- mainLogger.warn({ error }, 'Failed to initialize auto-updater')
285
- }
355
+ // Schedule periodic checks
356
+ autoUpdater.schedulePeriodicCheck()
357
+
358
+ cleanup.register(async () => {
359
+ autoUpdater?.stopPeriodicCheck()
360
+ mainLogger.info('Auto-updater stopped')
361
+ })
362
+
363
+ mainLogger.info('Auto-updater initialized')
364
+ } catch (error) {
365
+ mainLogger.warn({ error }, 'Failed to initialize auto-updater')
286
366
  }
367
+ }
368
+
369
+ // Start HTTP server after MCP server is ready
370
+ setTimeout(async () => {
371
+ try {
372
+ await httpServer.start()
373
+ mainLogger.info({ port: config.port }, 'HTTP API server started')
287
374
 
288
- // Start HTTP server after MCP server is ready
289
- setTimeout(async () => {
375
+ // Drain hook queue after HTTP server is ready
290
376
  try {
291
- await httpServer.start()
292
- mainLogger.info({ port: config.port }, 'HTTP API server started')
293
-
294
- // Drain hook queue after HTTP server is ready
295
- try {
296
- const { drainQueue } = await import('@/hooks/queue')
297
- const drained = await drainQueue(config.port)
298
- if (drained > 0) {
299
- mainLogger.info({ drained }, 'Drained hook queue')
300
- }
301
- } catch (error) {
302
- mainLogger.debug({ error }, 'No hook queue to drain')
377
+ const { drainQueue } = await import('@/hooks/queue')
378
+ const drained = await drainQueue(config.port)
379
+ if (drained > 0) {
380
+ mainLogger.info({ drained }, 'Drained hook queue')
303
381
  }
304
382
  } catch (error) {
305
- mainLogger.error({ error }, 'Failed to start HTTP API server')
383
+ mainLogger.debug({ error }, 'No hook queue to drain')
306
384
  }
307
- }, 2000)
308
- } else {
309
- mainLogger.info({ existingPid }, 'Secondary instance — HTTP server already running, starting MCP stdio only')
310
- }
311
-
312
- const shutdown = async (signal: string) => {
313
- mainLogger.info({ signal }, 'Shutting down...')
314
- await cleanup.cleanup()
315
- process.exit(0)
316
- }
385
+ } catch (error) {
386
+ mainLogger.error({ error }, 'Failed to start HTTP API server')
387
+ }
388
+ }, 2000)
317
389
 
390
+ // ── Signal handlers ──────────────────────────────────────
318
391
  process.on('SIGTERM', () => shutdown('SIGTERM'))
319
392
  process.on('SIGINT', () => shutdown('SIGINT'))
393
+ process.on('SIGHUP', () => shutdown('SIGHUP'))
320
394
 
321
395
  if (httpOnly) {
322
- // HTTP-only mode: no MCP stdio. Keep process alive for HTTP server.
396
+ // HTTP-only daemon mode: no MCP stdio. Use idle watchdog instead of infinite keepAlive.
323
397
  mainLogger.info('HTTP-only mode — MCP stdio disabled (background daemon)')
324
- // Keep the event loop alive with a long interval
325
- const keepAlive = setInterval(() => {}, 60_000 * 60)
326
- cleanup.register(async () => { clearInterval(keepAlive) })
398
+
399
+ const idleTimeoutMs = (config.daemon?.idleTimeoutMinutes ?? 30) * 60 * 1000
400
+ if (idleTimeoutMs > 0) {
401
+ const watchdog = setInterval(() => {
402
+ const idleMs = pidManager.getIdleMs()
403
+ if (idleMs >= idleTimeoutMs) {
404
+ mainLogger.info({ idleMs, idleTimeoutMs }, 'Idle timeout reached — shutting down daemon')
405
+ clearInterval(watchdog)
406
+ shutdown('idle-timeout')
407
+ }
408
+ }, 60_000)
409
+ cleanup.register(async () => clearInterval(watchdog))
410
+ mainLogger.info({ idleTimeoutMinutes: config.daemon?.idleTimeoutMinutes ?? 30 }, 'Idle watchdog started')
411
+ } else {
412
+ // idleTimeoutMinutes=0 means never self-terminate (old behavior)
413
+ const keepAlive = setInterval(() => {}, 60_000 * 60)
414
+ cleanup.register(async () => clearInterval(keepAlive))
415
+ mainLogger.info('Idle watchdog disabled (idleTimeoutMinutes=0)')
416
+ }
327
417
  } else {
418
+ // MCP stdio mode: start MCP server and detect stdin closure
328
419
  await mcpServer.start()
329
420
  mainLogger.info('Claude Brain MCP server ready and listening for connections')
421
+
422
+ // Detect stdin close — Claude Code disconnected
423
+ process.stdin.on('end', () => shutdown('stdin-end'))
424
+ process.stdin.on('close', () => shutdown('stdin-close'))
330
425
  }
331
426
  }
@@ -148,7 +148,7 @@ export async function loadConfig(basePath: string = process.cwd()): Promise<Conf
148
148
  const fileConfig = loadFromFile(basePath)
149
149
  const envConfig = loadFromEnv()
150
150
 
151
- const merged = mergeConfigs(defaultConfig as Partial<Config>, fileConfig, envConfig)
151
+ const merged = mergeConfigs(defaultConfig as Partial<Config>, envConfig, fileConfig)
152
152
 
153
153
  const result = ConfigSchema.safeParse(merged)
154
154
 
@@ -163,9 +163,14 @@ export async function loadConfig(basePath: string = process.cwd()): Promise<Conf
163
163
  const data = result.data
164
164
  data.logFilePath = resolveHomePath(data.logFilePath)
165
165
  data.dbPath = resolveHomePath(data.dbPath)
166
- if (data.knowledge?.graph?.persistPath) {
167
- data.knowledge.graph.persistPath = resolveHomePath(data.knowledge.graph.persistPath)
168
- }
166
+
167
+ // Always resolve knowledge graph path to absolute
168
+ // Zod .default({}) on nested objects means graph/persistPath may not exist
169
+ if (!data.knowledge) data.knowledge = {} as any
170
+ if (!data.knowledge.graph) data.knowledge.graph = {} as any
171
+ data.knowledge.graph.persistPath = resolveHomePath(
172
+ data.knowledge.graph.persistPath || './data/knowledge-graph.json'
173
+ )
169
174
 
170
175
  // Phase 30: Auto-detect vaultPath if not configured
171
176
  if (!data.vaultPath) {
@@ -290,7 +290,7 @@ export const ConfigSchema = z.object({
290
290
  serverVersion: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semver format').default(pkg.version),
291
291
 
292
292
  /** Logging level */
293
- logLevel: LogLevelSchema.default('info'),
293
+ logLevel: LogLevelSchema.default('warn'),
294
294
 
295
295
  /** Path to log file */
296
296
  logFilePath: z.string().default('./logs/claude-brain.log'),
@@ -386,6 +386,12 @@ export const ConfigSchema = z.object({
386
386
  }).default({} as any),
387
387
  }).default({} as any),
388
388
 
389
+ /** Daemon process configuration */
390
+ daemon: z.object({
391
+ /** Minutes of inactivity before daemon self-terminates (0 = never) */
392
+ idleTimeoutMinutes: z.number().int().min(0).default(30),
393
+ }).default({} as any),
394
+
389
395
  /** Phase 30: Optional LLM compression for observations */
390
396
  compression: z.object({
391
397
  /** Enable LLM-based compression of long observations */
@@ -27,6 +27,7 @@ export class HttpApiServer {
27
27
  private codeIndexer?: CodeIndexer
28
28
  private codeQuery?: CodeQuery
29
29
  private codeLinker?: MemoryCodeLinker
30
+ private activityTracker?: () => void
30
31
  private hookStats: HookStats = {
31
32
  totalCaptured: 0,
32
33
  totalSkipped: 0,
@@ -44,8 +45,16 @@ export class HttpApiServer {
44
45
  this.setupRoutes()
45
46
  }
46
47
 
48
+ /** Set activity tracker callback — called on every HTTP request for idle watchdog */
49
+ setActivityTracker(fn: () => void): void {
50
+ this.activityTracker = fn
51
+ }
52
+
47
53
  private setupMiddleware(): void {
48
54
  this.app.use('*', async (c, next) => {
55
+ // Touch activity tracker on every request (idle watchdog)
56
+ this.activityTracker?.()
57
+
49
58
  c.header('Access-Control-Allow-Origin', '*')
50
59
  c.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
51
60
  c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
@@ -137,6 +146,10 @@ export class HttpApiServer {
137
146
  // Phase 23b: Expose brain://context/auto via HTTP for testability
138
147
  this.app.get('/api/context/auto', () => this.handleContextAuto())
139
148
 
149
+ // MCP proxy endpoints — allow thin proxy clients to forward tool calls via HTTP
150
+ this.app.get('/api/mcp/list-tools', () => this.handleMcpListTools())
151
+ this.app.post('/api/mcp/call-tool', (c) => this.handleMcpCallTool(c))
152
+
140
153
  // Phase 6A: SLM feedback & model status endpoints
141
154
  this.app.get('/api/models/status', () => this.handleModelsStatus())
142
155
  this.app.get('/api/models/feedback', (c) => this.handleModelsFeedback(c))
@@ -1129,6 +1142,46 @@ export class HttpApiServer {
1129
1142
  }
1130
1143
  }
1131
1144
 
1145
+ // ─── MCP Proxy Endpoints ────────────────────────────────
1146
+
1147
+ private async handleMcpListTools(): Promise<Response> {
1148
+ try {
1149
+ const { handleListTools } = await import('@/server/handlers/list-tools')
1150
+ const result = await handleListTools()
1151
+ return Response.json({ success: true, data: result })
1152
+ } catch (error) {
1153
+ this.logger.error({ error }, 'Failed to list MCP tools')
1154
+ return Response.json(
1155
+ { success: false, error: 'Failed to list tools' },
1156
+ { status: 500 }
1157
+ )
1158
+ }
1159
+ }
1160
+
1161
+ private async handleMcpCallTool(c: any): Promise<Response> {
1162
+ try {
1163
+ const body = await c.req.json()
1164
+ const { name, args } = body
1165
+
1166
+ if (!name) {
1167
+ return Response.json(
1168
+ { success: false, error: 'name is required' },
1169
+ { status: 400 }
1170
+ )
1171
+ }
1172
+
1173
+ const { handleCallTool } = await import('@/server/handlers/call-tool')
1174
+ const result = await handleCallTool(name, args || {}, this.logger)
1175
+ return Response.json({ success: true, data: result })
1176
+ } catch (error: any) {
1177
+ this.logger.error({ error }, 'Failed to call MCP tool')
1178
+ return Response.json(
1179
+ { success: false, error: error?.message || 'Failed to call tool' },
1180
+ { status: 500 }
1181
+ )
1182
+ }
1183
+ }
1184
+
1132
1185
  async start(): Promise<void> {
1133
1186
  const port = this.config.port || 3000
1134
1187
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Thin MCP Proxy — speaks MCP over stdio, forwards everything via HTTP to the daemon.
3
+ * ~5MB footprint per Claude Code session. Dies when stdin closes.
4
+ */
5
+
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
+ import {
9
+ CallToolRequestSchema,
10
+ ListToolsRequestSchema,
11
+ ListPromptsRequestSchema,
12
+ ListResourcesRequestSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js'
14
+
15
+ interface McpProxyOptions {
16
+ daemonUrl: string
17
+ serverName: string
18
+ serverVersion: string
19
+ onStdinClose: () => void
20
+ }
21
+
22
+ export class McpProxyServer {
23
+ private server: Server
24
+ private daemonUrl: string
25
+ private onStdinClose: () => void
26
+
27
+ constructor(opts: McpProxyOptions) {
28
+ this.daemonUrl = opts.daemonUrl
29
+ this.onStdinClose = opts.onStdinClose
30
+
31
+ this.server = new Server(
32
+ { name: opts.serverName, version: opts.serverVersion },
33
+ { capabilities: { tools: {} } }
34
+ )
35
+
36
+ this.setupHandlers()
37
+ }
38
+
39
+ private setupHandlers(): void {
40
+ // Forward list-tools to daemon
41
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
42
+ const res = await fetch(`${this.daemonUrl}/api/mcp/list-tools`)
43
+ const json = await res.json() as any
44
+ if (!json.success) throw new Error(json.error || 'list-tools failed')
45
+ return json.data
46
+ })
47
+
48
+ // Forward call-tool to daemon
49
+ this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
50
+ const res = await fetch(`${this.daemonUrl}/api/mcp/call-tool`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({
54
+ name: request.params.name,
55
+ args: request.params.arguments || {},
56
+ }),
57
+ })
58
+ const json = await res.json() as any
59
+ if (!json.success) throw new Error(json.error || 'call-tool failed')
60
+ return json.data
61
+ })
62
+
63
+ // Prompts/Resources — return empty lists (daemon has these but proxy doesn't need them)
64
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }))
65
+ this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }))
66
+
67
+ this.server.onerror = (error) => {
68
+ console.error('[mcp-proxy] Server error:', error)
69
+ }
70
+ }
71
+
72
+ async start(): Promise<void> {
73
+ const transport = new StdioServerTransport()
74
+ await this.server.connect(transport)
75
+
76
+ // Detect stdin close — Claude Code disconnected
77
+ process.stdin.on('end', () => this.onStdinClose())
78
+ process.stdin.on('close', () => this.onStdinClose())
79
+ }
80
+
81
+ async stop(): Promise<void> {
82
+ await this.server.close()
83
+ }
84
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * PID file manager for the Claude Brain server.
3
3
  * Ensures only one server instance runs at a time.
4
+ * Tracks daemon port and activity for idle watchdog.
4
5
  */
5
6
 
6
7
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
@@ -9,37 +10,82 @@ import { getHomePaths } from '@/config/home'
9
10
 
10
11
  const PID_FILENAME = 'server.pid'
11
12
 
13
+ interface PidFileContent {
14
+ pid: number
15
+ port: number
16
+ }
17
+
12
18
  export class ServerPidManager {
13
19
  private pidFilePath: string
20
+ private lastActivityTs: number = Date.now()
14
21
 
15
22
  constructor() {
16
23
  const paths = getHomePaths()
17
24
  this.pidFilePath = join(paths.data, PID_FILENAME)
18
25
  }
19
26
 
20
- /** Check if another server instance is already running. Returns the PID if alive, null otherwise. */
21
- getRunningPid(): number | null {
27
+ /** Record activity call on every HTTP request or MCP tool call */
28
+ touchActivity(): void {
29
+ this.lastActivityTs = Date.now()
30
+ }
31
+
32
+ /** Milliseconds since last activity */
33
+ getIdleMs(): number {
34
+ return Date.now() - this.lastActivityTs
35
+ }
36
+
37
+ /**
38
+ * Get running daemon instance info (PID + port).
39
+ * Returns null if no daemon is running.
40
+ * Backward-compatible with old plain-number PID files.
41
+ */
42
+ getRunningInstance(): { pid: number; port: number } | null {
22
43
  if (!existsSync(this.pidFilePath)) return null
23
44
 
24
45
  try {
25
- const pid = parseInt(readFileSync(this.pidFilePath, 'utf-8').trim(), 10)
46
+ const raw = readFileSync(this.pidFilePath, 'utf-8').trim()
47
+
48
+ let pid: number
49
+ let port: number
50
+
51
+ // Try JSON format first (new)
52
+ if (raw.startsWith('{')) {
53
+ const parsed: PidFileContent = JSON.parse(raw)
54
+ pid = parsed.pid
55
+ port = parsed.port
56
+ } else {
57
+ // Backward compat: old plain-number format
58
+ pid = parseInt(raw, 10)
59
+ port = 3000 // default port assumption for old format
60
+ }
61
+
26
62
  if (isNaN(pid)) {
27
63
  this.cleanup()
28
64
  return null
29
65
  }
66
+
30
67
  // Signal 0 tests if process exists without killing it
31
68
  process.kill(pid, 0)
32
- return pid
69
+ return { pid, port }
33
70
  } catch {
34
- // Process not running, clean up stale PID file
71
+ // Process not running or invalid file, clean up stale PID file
35
72
  this.cleanup()
36
73
  return null
37
74
  }
38
75
  }
39
76
 
40
- /** Write the current process PID to the PID file. */
41
- writePidFile(): void {
42
- writeFileSync(this.pidFilePath, String(process.pid), 'utf-8')
77
+ /** Check if another server instance is already running. Returns the PID if alive, null otherwise. */
78
+ getRunningPid(): number | null {
79
+ return this.getRunningInstance()?.pid ?? null
80
+ }
81
+
82
+ /** Write the current process PID and port to the PID file. */
83
+ writePidFile(port?: number): void {
84
+ const content: PidFileContent = {
85
+ pid: process.pid,
86
+ port: port ?? 3000,
87
+ }
88
+ writeFileSync(this.pidFilePath, JSON.stringify(content), 'utf-8')
43
89
  }
44
90
 
45
91
  /** Remove the PID file. Safe to call multiple times. */
@@ -51,7 +97,7 @@ export class ServerPidManager {
51
97
  }
52
98
  }
53
99
 
54
- /** Register cleanup handlers on SIGINT, SIGTERM, and process exit. */
100
+ /** Register cleanup handlers on SIGINT, SIGTERM, SIGHUP, and process exit. */
55
101
  registerCleanupHandlers(): void {
56
102
  const doCleanup = () => {
57
103
  this.cleanup()
@@ -60,5 +106,6 @@ export class ServerPidManager {
60
106
  process.on('exit', doCleanup)
61
107
  process.on('SIGINT', doCleanup)
62
108
  process.on('SIGTERM', doCleanup)
109
+ process.on('SIGHUP', doCleanup)
63
110
  }
64
111
  }
@@ -178,7 +178,9 @@ export async function initializeServices(config: Config, logger: Logger): Promis
178
178
  if (config.knowledge?.graph?.enabled !== false) {
179
179
  try {
180
180
  const graph = new InMemoryKnowledgeGraph(logger)
181
- const graphPersistPath = config.knowledge?.graph?.persistPath || './data/knowledge-graph.json'
181
+ // Use absolute path from getHomePaths() if persistPath not configured
182
+ const { getHomePaths: getHomePathsKG } = await import('@/config/home')
183
+ const graphPersistPath = config.knowledge?.graph?.persistPath || getHomePathsKG().knowledgeGraph
182
184
 
183
185
  // Load existing graph from disk
184
186
  await graph.load(graphPersistPath)
@@ -656,7 +658,9 @@ export async function shutdownServices(): Promise<void> {
656
658
  // Save and stop knowledge graph
657
659
  if (services.knowledgeGraph) {
658
660
  services.knowledgeGraph.graph.stopAutoSave()
659
- const persistPath = services.config.knowledge?.graph?.persistPath || './data/knowledge-graph.json'
661
+ // Use absolute path from getHomePaths() if persistPath not configured
662
+ const { getHomePaths } = await import('@/config/home')
663
+ const persistPath = services.config.knowledge?.graph?.persistPath || getHomePaths().knowledgeGraph
660
664
  try {
661
665
  await services.knowledgeGraph.graph.save(persistPath)
662
666
  serviceLogger.info('Knowledge graph saved on shutdown')
@@ -29,6 +29,10 @@ export class GlobalErrorHandler {
29
29
  process.on('SIGINT', () => {
30
30
  this.handleShutdown('SIGINT')
31
31
  })
32
+
33
+ process.on('SIGHUP', () => {
34
+ this.handleShutdown('SIGHUP')
35
+ })
32
36
  }
33
37
 
34
38
  private handleUncaughtException(error: Error): void {