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 +1 -1
- package/package.json +1 -1
- package/src/cli/commands/serve.ts +233 -145
- package/src/config/schema.ts +6 -0
- 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/utils/error-handler.ts +4 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.29.1
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
252
|
+
cleanup.register(async () => {
|
|
253
|
+
pidManager.cleanup()
|
|
254
|
+
})
|
|
192
255
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
244
|
-
|
|
298
|
+
// Phase 30: Activity log pruning on startup + periodic cleanup
|
|
299
|
+
{
|
|
300
|
+
const memory = getMemoryService()
|
|
301
|
+
if (memory?.isInitialized()) {
|
|
245
302
|
try {
|
|
246
|
-
const {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
312
|
+
mainLogger.warn({ error }, 'Failed to initialize activity log pruning')
|
|
254
313
|
}
|
|
255
314
|
}
|
|
315
|
+
}
|
|
256
316
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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.
|
|
383
|
+
mainLogger.debug({ error }, 'No hook queue to drain')
|
|
313
384
|
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
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
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
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
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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: {}, 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
|
-
/**
|
|
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
|
}
|