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 +1 -1
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +237 -142
- package/src/config/loader.ts +9 -4
- package/src/config/schema.ts +7 -1
- package/src/server/http-api.ts +53 -0
- package/src/server/mcp-proxy.ts +84 -0
- package/src/server/pid-manager.ts +56 -9
- package/src/server/services.ts +6 -2
- package/src/utils/error-handler.ts +4 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.29.0
|
package/package.json
CHANGED
|
@@ -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
|
|
30
|
-
|
|
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 (!
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
252
|
+
cleanup.register(async () => {
|
|
253
|
+
pidManager.cleanup()
|
|
254
|
+
})
|
|
185
255
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
237
|
-
|
|
298
|
+
// Phase 30: Activity log pruning on startup + periodic cleanup
|
|
299
|
+
{
|
|
300
|
+
const memory = getMemoryService()
|
|
301
|
+
if (memory?.isInitialized()) {
|
|
238
302
|
try {
|
|
239
|
-
const {
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
312
|
+
mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
|
|
247
313
|
}
|
|
248
314
|
}
|
|
315
|
+
}
|
|
249
316
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
setTimeout(async () => {
|
|
375
|
+
// Drain hook queue after HTTP server is ready
|
|
290
376
|
try {
|
|
291
|
-
await
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
|
383
|
+
mainLogger.debug({ error }, 'No hook queue to drain')
|
|
306
384
|
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
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
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
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
|
}
|
package/src/config/loader.ts
CHANGED
|
@@ -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>,
|
|
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
|
-
|
|
167
|
-
|
|
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) {
|
package/src/config/schema.ts
CHANGED
|
@@ -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('
|
|
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 */
|
package/src/server/http-api.ts
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
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
|
}
|
package/src/server/services.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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')
|