claude-brain 0.28.3 → 0.29.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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.28.3
1
+ 0.29.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.28.3",
3
+ "version": "0.29.1",
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,30 +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()
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
30
 
31
- // Prevent duplicate --http-only processes
32
- if (httpOnly && existingPid) {
33
- console.error(`[claude-brain] Primary instance already running (PID ${existingPid}). Exiting.`)
31
+ if (httpOnly && existingInstance) {
32
+ console.error(`[claude-brain] Primary instance already running (PID ${existingInstance.pid}). Exiting.`)
34
33
  process.exit(0)
35
34
  }
36
35
 
37
- const isSecondaryInstance = !httpOnly && !!existingPid
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()
44
+ }
45
+
46
+ // ── Start as daemon ────────────────────────────────────────
47
+ return runAsDaemon(httpOnly, pidManager)
48
+ }
38
49
 
39
- if (!isSecondaryInstance) {
40
- // Primary instance: write PID file and register cleanup
41
- pidManager.writePidFile()
42
- pidManager.registerCleanupHandlers()
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
43
60
  }
61
+ }
44
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) {
45
104
  // Auto-install Claude Code hooks (idempotent, non-fatal)
46
105
  try {
47
106
  const { installHooks } = await import('@/hooks/installer')
@@ -53,7 +112,7 @@ export async function runServe() {
53
112
  console.error(`[claude-brain] Hook auto-install skipped: ${error instanceof Error ? error.message : 'unknown error'}`)
54
113
  }
55
114
 
56
- // Auto-install shell auto-start if not already present (handles bun blocking postinstall)
115
+ // Auto-install shell auto-start if not already present
57
116
  try {
58
117
  const { isAutoStartInstalled, installAutoStart } = await import('@/cli/auto-start')
59
118
  if (!isAutoStartInstalled()) {
@@ -66,7 +125,7 @@ export async function runServe() {
66
125
  // Non-fatal — auto-start is a convenience feature
67
126
  }
68
127
 
69
- // Auto-install CLAUDE.md if not present (handles bun blocking postinstall)
128
+ // Auto-install CLAUDE.md if not present
70
129
  try {
71
130
  const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs')
72
131
  const { join, resolve, dirname } = await import('node:path')
@@ -150,6 +209,7 @@ export async function runServe() {
150
209
  mainLogger.error({ error: error.message }, 'MCP server error')
151
210
  },
152
211
  onRequest: (toolName, requestId) => {
212
+ pidManager.touchActivity()
153
213
  mainLogger.debug({ toolName, requestId }, 'Tool request received')
154
214
  },
155
215
  onResponse: (toolName, requestId, durationMs) => {
@@ -174,165 +234,193 @@ export async function runServe() {
174
234
  await shutdownServices()
175
235
  })
176
236
 
177
- // ── Primary instance only: HTTP server + background services ──
178
- // Secondary instances (when auto-start already running) only start MCP stdio
179
- if (!isSecondaryInstance) {
180
- // Clean up PID file during graceful shutdown
181
- cleanup.register(async () => {
182
- pidManager.cleanup()
183
- })
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
+ }
184
246
 
185
- // Start HTTP API server alongside MCP server
186
- const { HttpApiServer } = await import('@/server/http-api')
187
- 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()
188
251
 
189
- cleanup.register(async () => {
190
- await httpServer.stop()
191
- })
252
+ cleanup.register(async () => {
253
+ pidManager.cleanup()
254
+ })
192
255
 
193
- // Phase 21: Use session tracker from services (promoted from local creation)
194
- {
195
- const { getSessionTracker } = await import('@/server/services')
196
- const sessionTracker = getSessionTracker()
197
- if (sessionTracker) {
198
- httpServer.setSessionTracker(sessionTracker)
199
- mainLogger.info('Session tracker wired to HTTP server')
200
- }
201
- }
256
+ // Start HTTP API server
257
+ const { HttpApiServer } = await import('@/server/http-api')
258
+ const httpServer = new HttpApiServer(config, logger)
202
259
 
203
- // Phase 28: Wire code intelligence to HTTP server
204
- {
205
- const { getCodeIndexer, getCodeQuery } = await import('@/server/services')
206
- const codeIndexer = getCodeIndexer()
207
- const codeQuery = getCodeQuery()
208
- if (codeIndexer && codeQuery) {
209
- httpServer.setCodeIntelligence(codeIndexer, codeQuery)
210
- mainLogger.info('Code intelligence wired to HTTP server')
211
- }
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')
212
274
  }
275
+ }
213
276
 
214
- // Phase 29: Wire code linker to HTTP server
215
- {
216
- const { getCodeLinker } = await import('@/server/services')
217
- const codeLinker = getCodeLinker()
218
- if (codeLinker) {
219
- httpServer.setCodeLinker(codeLinker)
220
- mainLogger.info('Code linker wired to HTTP server')
221
- }
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')
222
285
  }
286
+ }
223
287
 
224
- // Phase 30: Activity log pruning on startup + periodic cleanup
225
- {
226
- const memory = getMemoryService()
227
- if (memory?.isInitialized()) {
228
- try {
229
- const { startPeriodicPruning } = await import('@/memory/pruning')
230
- const db = memory.database.getDb()
231
- const stopPruning = startPeriodicPruning(db, logger, 30)
232
- cleanup.register(async () => {
233
- stopPruning()
234
- mainLogger.info('Activity log pruning stopped')
235
- })
236
- mainLogger.info('Activity log pruning initialized')
237
- } catch (error) {
238
- mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
239
- }
240
- }
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')
241
295
  }
296
+ }
242
297
 
243
- // Phase 30: Optional LLM compression
244
- if (config.compression?.enabled) {
298
+ // Phase 30: Activity log pruning on startup + periodic cleanup
299
+ {
300
+ const memory = getMemoryService()
301
+ if (memory?.isInitialized()) {
245
302
  try {
246
- const { ObservationCompressor } = await import('@/memory/compression')
247
- const { getBrainRouter } = await import('@/routing/router')
248
- const compressor = new ObservationCompressor(config.compression, logger)
249
- const router = getBrainRouter(logger)
250
- router.setCompressor(compressor)
251
- 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')
252
311
  } catch (error) {
253
- mainLogger.warn({ error }, 'Failed to initialize LLM compression')
312
+ mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
254
313
  }
255
314
  }
315
+ }
256
316
 
257
- // Phase 31: Auto-update checker
258
- let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
259
- if (config.autoUpdate?.enabled !== false) {
260
- try {
261
- const { AutoUpdater } = await import('@/server/auto-updater')
262
- autoUpdater = new AutoUpdater(
263
- {
264
- enabled: config.autoUpdate?.enabled ?? true,
265
- checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
266
- autoRestart: config.autoUpdate?.autoRestart ?? true,
267
- },
268
- mainLogger
269
- )
270
-
271
- // Check for updates on startup (non-blocking)
272
- autoUpdater.check().then(result => {
273
- if (result.updateAvailable && result.latestVersion) {
274
- mainLogger.info(
275
- { current: result.currentVersion, latest: result.latestVersion },
276
- 'Update available! Run: claude-brain update'
277
- )
278
- }
279
- }).catch(() => {})
280
-
281
- // Schedule periodic checks
282
- 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
+ }
283
330
 
284
- cleanup.register(async () => {
285
- autoUpdater?.stopPeriodicCheck()
286
- mainLogger.info('Auto-updater stopped')
287
- })
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(() => {})
288
354
 
289
- mainLogger.info('Auto-updater initialized')
290
- } catch (error) {
291
- mainLogger.warn({ error }, 'Failed to initialize auto-updater')
292
- }
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')
293
366
  }
367
+ }
294
368
 
295
- // Start HTTP server after MCP server is ready
296
- setTimeout(async () => {
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')
374
+
375
+ // Drain hook queue after HTTP server is ready
297
376
  try {
298
- await httpServer.start()
299
- mainLogger.info({ port: config.port }, 'HTTP API server started')
300
-
301
- // Drain hook queue after HTTP server is ready
302
- try {
303
- const { drainQueue } = await import('@/hooks/queue')
304
- const drained = await drainQueue(config.port)
305
- if (drained > 0) {
306
- mainLogger.info({ drained }, 'Drained hook queue')
307
- }
308
- } catch (error) {
309
- 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')
310
381
  }
311
382
  } catch (error) {
312
- mainLogger.error({ error }, 'Failed to start HTTP API server')
383
+ mainLogger.debug({ error }, 'No hook queue to drain')
313
384
  }
314
- }, 2000)
315
- } else {
316
- mainLogger.info({ existingPid }, 'Secondary instance — HTTP server already running, starting MCP stdio only')
317
- }
318
-
319
- const shutdown = async (signal: string) => {
320
- mainLogger.info({ signal }, 'Shutting down...')
321
- await cleanup.cleanup()
322
- process.exit(0)
323
- }
385
+ } catch (error) {
386
+ mainLogger.error({ error }, 'Failed to start HTTP API server')
387
+ }
388
+ }, 2000)
324
389
 
390
+ // ── Signal handlers ──────────────────────────────────────
325
391
  process.on('SIGTERM', () => shutdown('SIGTERM'))
326
392
  process.on('SIGINT', () => shutdown('SIGINT'))
393
+ process.on('SIGHUP', () => shutdown('SIGHUP'))
327
394
 
328
395
  if (httpOnly) {
329
- // 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.
330
397
  mainLogger.info('HTTP-only mode — MCP stdio disabled (background daemon)')
331
- // Keep the event loop alive with a long interval
332
- const keepAlive = setInterval(() => {}, 60_000 * 60)
333
- 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
+ }
334
417
  } else {
418
+ // MCP stdio mode: start MCP server and detect stdin closure
335
419
  await mcpServer.start()
336
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'))
337
425
  }
338
426
  }
@@ -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: {}, prompts: {}, resources: {} } }
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
  }
@@ -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 {