claude-brain 0.24.1 → 0.25.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/scripts/postinstall.mjs +3 -3
- package/src/cli/auto-start.ts +5 -5
- package/src/cli/commands/serve.ts +142 -124
- package/src/hooks/installer.ts +39 -2
- package/src/hooks/interceptor-hook.ts +200 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.25.0
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -342,7 +342,7 @@ function buildAutoStartSnippet(profilePath) {
|
|
|
342
342
|
'if (Get-Command claude-brain -ErrorAction SilentlyContinue) {',
|
|
343
343
|
' $listening = Get-NetTCPConnection -LocalPort 3000 -ErrorAction SilentlyContinue',
|
|
344
344
|
' if (-not $listening) {',
|
|
345
|
-
' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
|
|
345
|
+
' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve","--http-only" -WindowStyle Hidden',
|
|
346
346
|
' }',
|
|
347
347
|
'}',
|
|
348
348
|
AUTO_END_MARKER,
|
|
@@ -350,9 +350,9 @@ function buildAutoStartSnippet(profilePath) {
|
|
|
350
350
|
}
|
|
351
351
|
return [
|
|
352
352
|
AUTO_START_MARKER,
|
|
353
|
-
'# Auto-start claude-brain server if not already running',
|
|
353
|
+
'# Auto-start claude-brain HTTP server if not already running',
|
|
354
354
|
'(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :3000 >/dev/null 2>&1) && {',
|
|
355
|
-
' nohup claude-brain serve >/dev/null 2>&1 &',
|
|
355
|
+
' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
|
|
356
356
|
' disown 2>/dev/null',
|
|
357
357
|
'}',
|
|
358
358
|
AUTO_END_MARKER,
|
package/src/cli/auto-start.ts
CHANGED
|
@@ -32,9 +32,9 @@ export interface AutoStartResult {
|
|
|
32
32
|
function buildUnixSnippet(port: number): string {
|
|
33
33
|
return [
|
|
34
34
|
START_MARKER,
|
|
35
|
-
'# Auto-start claude-brain server if not already running',
|
|
35
|
+
'# Auto-start claude-brain HTTP server if not already running',
|
|
36
36
|
`(command -v claude-brain >/dev/null 2>&1 && ! lsof -ti :${port} >/dev/null 2>&1) && {`,
|
|
37
|
-
' nohup claude-brain serve >/dev/null 2>&1 &',
|
|
37
|
+
' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
|
|
38
38
|
' disown 2>/dev/null',
|
|
39
39
|
'}',
|
|
40
40
|
END_MARKER,
|
|
@@ -48,9 +48,9 @@ function buildUnixSnippet(port: number): string {
|
|
|
48
48
|
function buildFishSnippet(port: number): string {
|
|
49
49
|
return [
|
|
50
50
|
START_MARKER,
|
|
51
|
-
'# Auto-start claude-brain server if not already running',
|
|
51
|
+
'# Auto-start claude-brain HTTP server if not already running',
|
|
52
52
|
`if command -v claude-brain >/dev/null 2>&1; and not lsof -ti :${port} >/dev/null 2>&1`,
|
|
53
|
-
' nohup claude-brain serve >/dev/null 2>&1 &',
|
|
53
|
+
' nohup claude-brain serve --http-only >/dev/null 2>&1 &',
|
|
54
54
|
'end',
|
|
55
55
|
END_MARKER,
|
|
56
56
|
].join('\n')
|
|
@@ -66,7 +66,7 @@ function buildPowerShellSnippet(port: number): string {
|
|
|
66
66
|
` $port = ${port}`,
|
|
67
67
|
' $listening = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue',
|
|
68
68
|
' if (-not $listening) {',
|
|
69
|
-
' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve" -WindowStyle Hidden',
|
|
69
|
+
' Start-Process -NoNewWindow -FilePath "claude-brain" -ArgumentList "serve","--http-only" -WindowStyle Hidden',
|
|
70
70
|
' }',
|
|
71
71
|
'}',
|
|
72
72
|
END_MARKER,
|
|
@@ -18,17 +18,22 @@ export async function runServe() {
|
|
|
18
18
|
// Auto-initialize home directory on first run
|
|
19
19
|
ensureHomeDirectory()
|
|
20
20
|
|
|
21
|
-
//
|
|
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
|
+
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)
|
|
22
28
|
const pidManager = new ServerPidManager()
|
|
23
29
|
const existingPid = pidManager.getRunningPid()
|
|
24
|
-
|
|
25
|
-
console.error(`[claude-brain] Server already running (PID: ${existingPid}). Exiting.`)
|
|
26
|
-
process.exit(0)
|
|
27
|
-
}
|
|
30
|
+
const isSecondaryInstance = !httpOnly && !!existingPid
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
if (!isSecondaryInstance) {
|
|
33
|
+
// Primary instance: write PID file and register cleanup
|
|
34
|
+
pidManager.writePidFile()
|
|
35
|
+
pidManager.registerCleanupHandlers()
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
// Auto-install Claude Code hooks (idempotent, non-fatal)
|
|
34
39
|
try {
|
|
@@ -162,141 +167,147 @@ export async function runServe() {
|
|
|
162
167
|
await shutdownServices()
|
|
163
168
|
})
|
|
164
169
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
})
|
|
169
177
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
// Start HTTP API server alongside MCP server
|
|
179
|
+
const { HttpApiServer } = await import('@/server/http-api')
|
|
180
|
+
const httpServer = new HttpApiServer(config, logger)
|
|
173
181
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
cleanup.register(async () => {
|
|
183
|
+
await httpServer.stop()
|
|
184
|
+
})
|
|
177
185
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
194
|
}
|
|
186
|
-
}
|
|
187
195
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
205
|
}
|
|
197
|
-
}
|
|
198
206
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|
|
206
215
|
}
|
|
207
|
-
}
|
|
208
216
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
|
224
233
|
}
|
|
225
234
|
}
|
|
226
|
-
}
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
// Phase 30: Optional LLM compression
|
|
237
|
+
if (config.compression?.enabled) {
|
|
238
|
+
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')
|
|
245
|
+
} catch (error) {
|
|
246
|
+
mainLogger.warn({ error }, 'Failed to initialize LLM compression')
|
|
247
|
+
}
|
|
239
248
|
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Phase 31: Auto-update checker
|
|
243
|
-
let autoUpdater: InstanceType<typeof import('@/server/auto-updater').AutoUpdater> | null = null
|
|
244
|
-
if (config.autoUpdate?.enabled !== false) {
|
|
245
|
-
try {
|
|
246
|
-
const { AutoUpdater } = await import('@/server/auto-updater')
|
|
247
|
-
autoUpdater = new AutoUpdater(
|
|
248
|
-
{
|
|
249
|
-
enabled: config.autoUpdate?.enabled ?? true,
|
|
250
|
-
checkIntervalHours: config.autoUpdate?.checkIntervalHours ?? 24,
|
|
251
|
-
autoRestart: config.autoUpdate?.autoRestart ?? true,
|
|
252
|
-
},
|
|
253
|
-
mainLogger
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
// Check for updates on startup (non-blocking)
|
|
257
|
-
autoUpdater.check().then(result => {
|
|
258
|
-
if (result.updateAvailable && result.latestVersion) {
|
|
259
|
-
mainLogger.info(
|
|
260
|
-
{ current: result.currentVersion, latest: result.latestVersion },
|
|
261
|
-
'Update available! Run: claude-brain update'
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
}).catch(() => {})
|
|
265
249
|
|
|
266
|
-
|
|
267
|
-
|
|
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()
|
|
268
276
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
277
|
+
cleanup.register(async () => {
|
|
278
|
+
autoUpdater?.stopPeriodicCheck()
|
|
279
|
+
mainLogger.info('Auto-updater stopped')
|
|
280
|
+
})
|
|
273
281
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
282
|
+
mainLogger.info('Auto-updater initialized')
|
|
283
|
+
} catch (error) {
|
|
284
|
+
mainLogger.warn({ error }, 'Failed to initialize auto-updater')
|
|
285
|
+
}
|
|
277
286
|
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Start HTTP server after MCP server is ready
|
|
281
|
-
setTimeout(async () => {
|
|
282
|
-
try {
|
|
283
|
-
await httpServer.start()
|
|
284
|
-
mainLogger.info({ port: config.port }, 'HTTP API server started')
|
|
285
287
|
|
|
286
|
-
|
|
288
|
+
// Start HTTP server after MCP server is ready
|
|
289
|
+
setTimeout(async () => {
|
|
287
290
|
try {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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')
|
|
292
303
|
}
|
|
293
304
|
} catch (error) {
|
|
294
|
-
mainLogger.
|
|
305
|
+
mainLogger.error({ error }, 'Failed to start HTTP API server')
|
|
295
306
|
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
}
|
|
307
|
+
}, 2000)
|
|
308
|
+
} else {
|
|
309
|
+
mainLogger.info({ existingPid }, 'Secondary instance — HTTP server already running, starting MCP stdio only')
|
|
310
|
+
}
|
|
300
311
|
|
|
301
312
|
const shutdown = async (signal: string) => {
|
|
302
313
|
mainLogger.info({ signal }, 'Shutting down...')
|
|
@@ -307,7 +318,14 @@ export async function runServe() {
|
|
|
307
318
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
308
319
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
309
320
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
321
|
+
if (httpOnly) {
|
|
322
|
+
// HTTP-only mode: no MCP stdio. Keep process alive for HTTP server.
|
|
323
|
+
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) })
|
|
327
|
+
} else {
|
|
328
|
+
await mcpServer.start()
|
|
329
|
+
mainLogger.info('Claude Brain MCP server ready and listening for connections')
|
|
330
|
+
}
|
|
313
331
|
}
|
package/src/hooks/installer.ts
CHANGED
|
@@ -22,6 +22,11 @@ export function getContextHookScriptPath(): string {
|
|
|
22
22
|
return join(getClaudeBrainHome(), 'hooks', 'context-hook.ts')
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Get the path where the interceptor-hook script should be installed */
|
|
26
|
+
export function getInterceptorHookScriptPath(): string {
|
|
27
|
+
return join(getClaudeBrainHome(), 'hooks', 'interceptor-hook.ts')
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
/** Read Claude Code settings.json, creating if needed */
|
|
26
31
|
function readSettings(): Record<string, any> {
|
|
27
32
|
if (!existsSync(CLAUDE_SETTINGS_PATH)) return {}
|
|
@@ -47,8 +52,12 @@ function writeSettings(settings: Record<string, any>): void {
|
|
|
47
52
|
/** Build the hook command string, embedding the port so hooks work regardless of env.
|
|
48
53
|
* BUG-006: settings.json syncs across platforms. We use --port arg instead of
|
|
49
54
|
* platform-specific env var syntax (VAR=val on Unix, set VAR=val on Windows). */
|
|
50
|
-
function buildHookCommand(event: string, script: 'brain-hook' | 'context-hook' = 'brain-hook'): string {
|
|
51
|
-
const scriptPath = script === 'context-hook'
|
|
55
|
+
function buildHookCommand(event: string, script: 'brain-hook' | 'context-hook' | 'interceptor-hook' = 'brain-hook'): string {
|
|
56
|
+
const scriptPath = script === 'context-hook'
|
|
57
|
+
? getContextHookScriptPath()
|
|
58
|
+
: script === 'interceptor-hook'
|
|
59
|
+
? getInterceptorHookScriptPath()
|
|
60
|
+
: getHookScriptPath()
|
|
52
61
|
const port = process.env.PORT || process.env.CLAUDE_BRAIN_PORT || '3000'
|
|
53
62
|
|
|
54
63
|
return `bun "${scriptPath}" --event ${event} --port ${port} # ${HOOK_MARKER}`
|
|
@@ -108,6 +117,23 @@ export function installHooks(): { installed: boolean; message: string } {
|
|
|
108
117
|
}],
|
|
109
118
|
})
|
|
110
119
|
|
|
120
|
+
// PreToolUse interceptor — blocks Glob/Grep when code index has results
|
|
121
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []
|
|
122
|
+
settings.hooks.PreToolUse.push({
|
|
123
|
+
matcher: 'Glob',
|
|
124
|
+
hooks: [{
|
|
125
|
+
type: 'command',
|
|
126
|
+
command: buildHookCommand('PreToolUse', 'interceptor-hook'),
|
|
127
|
+
}],
|
|
128
|
+
})
|
|
129
|
+
settings.hooks.PreToolUse.push({
|
|
130
|
+
matcher: 'Grep',
|
|
131
|
+
hooks: [{
|
|
132
|
+
type: 'command',
|
|
133
|
+
command: buildHookCommand('PreToolUse', 'interceptor-hook'),
|
|
134
|
+
}],
|
|
135
|
+
})
|
|
136
|
+
|
|
111
137
|
writeSettings(settings)
|
|
112
138
|
|
|
113
139
|
// Copy hook script to install location
|
|
@@ -167,6 +193,16 @@ export function uninstallHooks(): { uninstalled: boolean; message: string } {
|
|
|
167
193
|
}
|
|
168
194
|
}
|
|
169
195
|
|
|
196
|
+
// Remove our entries from PreToolUse
|
|
197
|
+
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
198
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
199
|
+
(entry: any) => !isOurHookEntry(entry)
|
|
200
|
+
)
|
|
201
|
+
if (settings.hooks.PreToolUse.length === 0) {
|
|
202
|
+
delete settings.hooks.PreToolUse
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
170
206
|
// Clean up empty hooks object
|
|
171
207
|
if (Object.keys(settings.hooks).length === 0) {
|
|
172
208
|
delete settings.hooks
|
|
@@ -209,6 +245,7 @@ function isOurHookEntry(entry: any): boolean {
|
|
|
209
245
|
const HOOK_FILES = [
|
|
210
246
|
'brain-hook.ts',
|
|
211
247
|
'context-hook.ts',
|
|
248
|
+
'interceptor-hook.ts',
|
|
212
249
|
'capture.ts',
|
|
213
250
|
'queue.ts',
|
|
214
251
|
'types.ts',
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Code Intelligence Interceptor — PreToolUse Hook
|
|
4
|
+
*
|
|
5
|
+
* Intercepts Glob and Grep calls, queries the code index,
|
|
6
|
+
* and blocks the tool when confident results exist.
|
|
7
|
+
*
|
|
8
|
+
* CRITICAL CONSTRAINTS:
|
|
9
|
+
* - Must complete in <3s (PreToolUse timeout)
|
|
10
|
+
* - Stderr = message shown to Claude when blocking
|
|
11
|
+
* - Exit 0 = allow tool, exit non-zero = block tool
|
|
12
|
+
* - NEVER write to stdout (corrupts JSON-RPC)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface HookStdin {
|
|
16
|
+
session_id: string
|
|
17
|
+
tool_name: string
|
|
18
|
+
tool_input: Record<string, any>
|
|
19
|
+
cwd?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SymbolResult {
|
|
23
|
+
symbol: string
|
|
24
|
+
type: string
|
|
25
|
+
filePath: string
|
|
26
|
+
lineStart: number
|
|
27
|
+
lineEnd?: number
|
|
28
|
+
signature?: string
|
|
29
|
+
confidence: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main(): Promise<void> {
|
|
33
|
+
const rawInput = await readStdin()
|
|
34
|
+
if (!rawInput.trim()) { process.exit(0); return }
|
|
35
|
+
|
|
36
|
+
let input: HookStdin
|
|
37
|
+
try { input = JSON.parse(rawInput) }
|
|
38
|
+
catch { process.exit(0); return }
|
|
39
|
+
|
|
40
|
+
// Only intercept Glob and Grep
|
|
41
|
+
if (!['Glob', 'Grep'].includes(input.tool_name)) {
|
|
42
|
+
process.exit(0)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Extract search intent from tool input
|
|
47
|
+
const searchTerm = extractSearchIntent(input)
|
|
48
|
+
if (!searchTerm) { process.exit(0); return }
|
|
49
|
+
|
|
50
|
+
// Detect project from cwd
|
|
51
|
+
const project = detectProject(input.cwd)
|
|
52
|
+
if (!project) { process.exit(0); return }
|
|
53
|
+
|
|
54
|
+
// Read port from --port arg or env
|
|
55
|
+
const portIdx = process.argv.indexOf('--port')
|
|
56
|
+
const portArg = portIdx >= 0 ? process.argv[portIdx + 1] : undefined
|
|
57
|
+
const port = parseInt(portArg || process.env.CLAUDE_BRAIN_PORT || '3000', 10)
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
query: searchTerm,
|
|
62
|
+
project,
|
|
63
|
+
limit: '10',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const res = await fetch(
|
|
67
|
+
`http://localhost:${port}/api/code/search?${params}`,
|
|
68
|
+
{ signal: AbortSignal.timeout(2000) }
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (!res.ok) { process.exit(0); return }
|
|
72
|
+
|
|
73
|
+
const data = await res.json() as { data?: SymbolResult[] }
|
|
74
|
+
const results: SymbolResult[] = Array.isArray(data.data) ? data.data : (data.data as any) || []
|
|
75
|
+
|
|
76
|
+
// Only block if we have confident results
|
|
77
|
+
if (results.length > 0 && results[0].confidence > 0.7) {
|
|
78
|
+
const message = formatResults(results, input.tool_name, searchTerm)
|
|
79
|
+
process.stderr.write(message)
|
|
80
|
+
process.exit(2) // non-zero = BLOCK
|
|
81
|
+
} else {
|
|
82
|
+
process.exit(0) // let original tool run
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Any error = let original tool run (safe fallback)
|
|
86
|
+
process.exit(0)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractSearchIntent(input: HookStdin): string | null {
|
|
91
|
+
const toolInput = input.tool_input
|
|
92
|
+
|
|
93
|
+
if (input.tool_name === 'Glob') {
|
|
94
|
+
const pattern = toolInput.pattern || ''
|
|
95
|
+
if (isGenericGlob(pattern)) return null
|
|
96
|
+
return extractGlobKeyword(pattern)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (input.tool_name === 'Grep') {
|
|
100
|
+
const pattern = toolInput.pattern || ''
|
|
101
|
+
if (isComplexRegex(pattern)) return null
|
|
102
|
+
return pattern
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Returns true for patterns like "**\/*.ts", "**\/*.js" that match too broadly */
|
|
109
|
+
function isGenericGlob(pattern: string): boolean {
|
|
110
|
+
return /^\*\*\/\*\.\w+$/.test(pattern)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Extract the meaningful keyword from a glob pattern */
|
|
114
|
+
function extractGlobKeyword(pattern: string): string | null {
|
|
115
|
+
const cleaned = pattern
|
|
116
|
+
.replace(/\*\*/g, '')
|
|
117
|
+
.replace(/\*/g, '')
|
|
118
|
+
.replace(/\//g, ' ')
|
|
119
|
+
.replace(/\.\w+$/g, '') // remove extension
|
|
120
|
+
.trim()
|
|
121
|
+
|
|
122
|
+
const words = cleaned.split(/\s+/).filter(w => w.length >= 3)
|
|
123
|
+
return words.length > 0 ? words.join(' ') : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Returns true for patterns that are complex regex, not simple symbol names */
|
|
127
|
+
function isComplexRegex(pattern: string): boolean {
|
|
128
|
+
// Simple word-like patterns are fine to intercept
|
|
129
|
+
if (/^\w+$/.test(pattern)) return false
|
|
130
|
+
// Patterns with regex meta-chars are complex
|
|
131
|
+
return /[\\()[\]{}|^$+?]/.test(pattern)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function detectProject(cwd?: string): string | null {
|
|
135
|
+
if (!cwd) return null
|
|
136
|
+
const segments = cwd.split('/').filter(Boolean)
|
|
137
|
+
return segments[segments.length - 1] || null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatResults(
|
|
141
|
+
results: SymbolResult[],
|
|
142
|
+
toolName: string,
|
|
143
|
+
searchTerm: string
|
|
144
|
+
): string {
|
|
145
|
+
const lines: string[] = [
|
|
146
|
+
`Code index found ${results.length} match${results.length === 1 ? '' : 'es'} for "${searchTerm}" (skip ${toolName}, use Read directly):`,
|
|
147
|
+
'',
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
for (const r of results.slice(0, 8)) {
|
|
151
|
+
const sig = r.signature ? ` ${r.signature}` : ''
|
|
152
|
+
const lineRange = r.lineEnd
|
|
153
|
+
? `${r.lineStart}-${r.lineEnd}`
|
|
154
|
+
: `${r.lineStart}`
|
|
155
|
+
lines.push(
|
|
156
|
+
` ${r.symbol} (${r.type})${sig} → ${r.filePath}:${lineRange}`
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (results.length > 8) {
|
|
161
|
+
lines.push(` ... and ${results.length - 8} more`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Add actionable tip
|
|
165
|
+
lines.push('')
|
|
166
|
+
const top = results[0]!
|
|
167
|
+
const offset = Math.max(1, top.lineStart - 5)
|
|
168
|
+
const limit = ((top.lineEnd || top.lineStart) + 20) - top.lineStart + 10
|
|
169
|
+
lines.push(
|
|
170
|
+
`Tip: Read("${top.filePath}", offset=${offset}, limit=${limit}) for the ${top.type} body.`
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return lines.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read all of stdin as a string */
|
|
177
|
+
function readStdin(): Promise<string> {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const chunks: Buffer[] = []
|
|
180
|
+
const stdin = process.stdin
|
|
181
|
+
|
|
182
|
+
stdin.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
183
|
+
stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
|
184
|
+
stdin.on('error', reject)
|
|
185
|
+
|
|
186
|
+
// Timeout after 2 seconds
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
stdin.destroy()
|
|
189
|
+
resolve(Buffer.concat(chunks).toString('utf-8'))
|
|
190
|
+
}, 2000)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Execute when run directly
|
|
195
|
+
const isDirectRun = process.argv[1]?.includes('interceptor-hook')
|
|
196
|
+
if (isDirectRun) {
|
|
197
|
+
main().catch(() => process.exit(0))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { main }
|