Merge main into feat/chroma-http-server
Resolve conflicts between Chroma HTTP server PR and main branch changes (folder CLAUDE.md, exclusion settings, Zscaler SSL, transport cleanup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* EnvManager - Centralized environment variable management for claude-mem
|
||||
*
|
||||
* Provides isolated credential storage in ~/.claude-mem/.env
|
||||
* This ensures claude-mem uses its own configured credentials,
|
||||
* not random ANTHROPIC_API_KEY values from project .env files.
|
||||
*
|
||||
* Issue #733: SDK was auto-discovering API keys from user's shell environment,
|
||||
* causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Path to claude-mem's centralized .env file
|
||||
const DATA_DIR = join(homedir(), '.claude-mem');
|
||||
export const ENV_FILE_PATH = join(DATA_DIR, '.env');
|
||||
|
||||
// Environment variables to STRIP from subprocess environment (blocklist approach)
|
||||
// Only ANTHROPIC_API_KEY is stripped because it's the specific variable that causes
|
||||
// Issue #733: project .env files set ANTHROPIC_API_KEY which the SDK auto-discovers,
|
||||
// causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
//
|
||||
// All other env vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, system vars, etc.)
|
||||
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
||||
const BLOCKED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||
];
|
||||
|
||||
// Credential keys that claude-mem manages
|
||||
export const MANAGED_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
export interface ClaudeMemEnv {
|
||||
// Credentials (optional - empty means use CLI billing for Claude)
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
GEMINI_API_KEY?: string;
|
||||
OPENROUTER_API_KEY?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .env file content into key-value pairs
|
||||
*/
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=value format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize key-value pairs to .env file format
|
||||
*/
|
||||
function serializeEnvFile(env: Record<string, string>): string {
|
||||
const lines: string[] = [
|
||||
'# claude-mem credentials',
|
||||
'# This file stores API keys for claude-mem memory agent',
|
||||
'# Edit this file or use claude-mem settings to configure',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value) {
|
||||
// Quote values that contain spaces or special characters
|
||||
const needsQuotes = /[\s#=]/.test(value);
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from ~/.claude-mem/.env
|
||||
* Returns empty object if file doesn't exist (means use CLI billing)
|
||||
*/
|
||||
export function loadClaudeMemEnv(): ClaudeMemEnv {
|
||||
if (!existsSync(ENV_FILE_PATH)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ENV_FILE_PATH, 'utf-8');
|
||||
const parsed = parseEnvFile(content);
|
||||
|
||||
// Only return managed credential keys
|
||||
const result: ClaudeMemEnv = {};
|
||||
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
|
||||
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
|
||||
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn('ENV', 'Failed to load .env file', { path: ENV_FILE_PATH }, error as Error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to ~/.claude-mem/.env
|
||||
*/
|
||||
export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing to preserve any extra keys
|
||||
const existing = existsSync(ENV_FILE_PATH)
|
||||
? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
// Update with new values
|
||||
const updated: Record<string, string> = { ...existing };
|
||||
|
||||
// Only update managed keys
|
||||
if (env.ANTHROPIC_API_KEY !== undefined) {
|
||||
if (env.ANTHROPIC_API_KEY) {
|
||||
updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
delete updated.ANTHROPIC_API_KEY;
|
||||
}
|
||||
}
|
||||
if (env.GEMINI_API_KEY !== undefined) {
|
||||
if (env.GEMINI_API_KEY) {
|
||||
updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
|
||||
} else {
|
||||
delete updated.GEMINI_API_KEY;
|
||||
}
|
||||
}
|
||||
if (env.OPENROUTER_API_KEY !== undefined) {
|
||||
if (env.OPENROUTER_API_KEY) {
|
||||
updated.OPENROUTER_API_KEY = env.OPENROUTER_API_KEY;
|
||||
} else {
|
||||
delete updated.OPENROUTER_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean environment for spawning SDK subprocesses
|
||||
*
|
||||
* Uses a BLOCKLIST approach: inherits the full process environment but strips
|
||||
* only ANTHROPIC_API_KEY to prevent Issue #733 (accidental billing from project .env files).
|
||||
*
|
||||
* All other variables pass through, including:
|
||||
* - ANTHROPIC_AUTH_TOKEN (CLI subscription auth)
|
||||
* - ANTHROPIC_BASE_URL (custom proxy endpoints)
|
||||
* - Platform-specific vars (USERPROFILE, XDG_*, etc.)
|
||||
*
|
||||
* If claude-mem has an explicit ANTHROPIC_API_KEY in ~/.claude-mem/.env, it's re-injected
|
||||
* after stripping, so the managed credential takes precedence over any ambient value.
|
||||
*
|
||||
* @param includeCredentials - Whether to include API keys from ~/.claude-mem/.env (default: true)
|
||||
*/
|
||||
export function buildIsolatedEnv(includeCredentials: boolean = true): Record<string, string> {
|
||||
// 1. Start with full process environment
|
||||
const isolatedEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !BLOCKED_ENV_VARS.includes(key)) {
|
||||
isolatedEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Override SDK entrypoint marker
|
||||
isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
|
||||
|
||||
// 3. Re-inject managed credentials from claude-mem's .env file
|
||||
if (includeCredentials) {
|
||||
const credentials = loadClaudeMemEnv();
|
||||
|
||||
// Only add ANTHROPIC_API_KEY if explicitly configured in claude-mem
|
||||
// If not configured, CLI billing will be used (via ANTHROPIC_AUTH_TOKEN passthrough)
|
||||
if (credentials.ANTHROPIC_API_KEY) {
|
||||
isolatedEnv.ANTHROPIC_API_KEY = credentials.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// Note: GEMINI_API_KEY and OPENROUTER_API_KEY pass through from process.env,
|
||||
// but claude-mem's .env takes precedence if configured
|
||||
if (credentials.GEMINI_API_KEY) {
|
||||
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
|
||||
}
|
||||
if (credentials.OPENROUTER_API_KEY) {
|
||||
isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
// 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing)
|
||||
// When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing
|
||||
// which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN.
|
||||
// The worker inherits this token from the Claude Code session that started it.
|
||||
if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
return isolatedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific credential from claude-mem's .env
|
||||
* Returns undefined if not set (which means use default/CLI billing)
|
||||
*/
|
||||
export function getCredential(key: keyof ClaudeMemEnv): string | undefined {
|
||||
const env = loadClaudeMemEnv();
|
||||
return env[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific credential in claude-mem's .env
|
||||
* Pass empty string to remove the credential
|
||||
*/
|
||||
export function setCredential(key: keyof ClaudeMemEnv, value: string): void {
|
||||
const env = loadClaudeMemEnv();
|
||||
env[key] = value || undefined;
|
||||
saveClaudeMemEnv(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if claude-mem has an Anthropic API key configured
|
||||
* If false, it means CLI billing should be used
|
||||
*/
|
||||
export function hasAnthropicApiKey(): boolean {
|
||||
const env = loadClaudeMemEnv();
|
||||
return !!env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth method description for logging
|
||||
*/
|
||||
export function getAuthMethodDescription(): string {
|
||||
if (hasAnthropicApiKey()) {
|
||||
return 'API key (from ~/.claude-mem/.env)';
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return 'Claude Code OAuth token (from parent process)';
|
||||
}
|
||||
return 'Claude Code CLI (subscription billing)';
|
||||
}
|
||||
@@ -20,8 +20,9 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_SKIP_TOOLS: string;
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini' | 'openrouter'
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string; // 'cli' | 'api' - how Claude provider authenticates
|
||||
CLAUDE_MEM_GEMINI_API_KEY: string;
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash'
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash-preview'
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; // 'true' | 'false' - enable rate limiting for free tier
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: string;
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: string;
|
||||
@@ -50,6 +51,10 @@ export interface SettingsDefaults {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
||||
CLAUDE_MEM_CHROMA_HOST: string;
|
||||
@@ -73,6 +78,7 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key)
|
||||
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
|
||||
@@ -103,6 +109,10 @@ export class SettingsDefaultsManager {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' starts npx chroma run, 'remote' connects to existing server
|
||||
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
|
||||
@@ -138,16 +148,36 @@ export class SettingsDefaultsManager {
|
||||
|
||||
/**
|
||||
* Get a boolean default value
|
||||
* Handles both string 'true' and boolean true from JSON
|
||||
*/
|
||||
static getBool(key: keyof SettingsDefaults): boolean {
|
||||
const value = this.get(key);
|
||||
return value === 'true';
|
||||
return value === 'true' || value === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply environment variable overrides to settings
|
||||
* Environment variables take highest priority over file and defaults
|
||||
*/
|
||||
private static applyEnvOverrides(settings: SettingsDefaults): SettingsDefaults {
|
||||
const result = { ...settings };
|
||||
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
|
||||
if (process.env[key] !== undefined) {
|
||||
result[key] = process.env[key]!;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from file with fallback to defaults
|
||||
* Returns merged settings with defaults as fallback
|
||||
* Handles all errors (missing file, corrupted JSON, permissions) by returning defaults
|
||||
* Returns merged settings with proper priority: process.env > settings file > defaults
|
||||
* Handles all errors (missing file, corrupted JSON, permissions) gracefully
|
||||
*
|
||||
* Configuration Priority:
|
||||
* 1. Environment variables (highest priority)
|
||||
* 2. Settings file (~/.claude-mem/settings.json)
|
||||
* 3. Default values (lowest priority)
|
||||
*/
|
||||
static loadFromFile(settingsPath: string): SettingsDefaults {
|
||||
try {
|
||||
@@ -164,7 +194,8 @@ export class SettingsDefaultsManager {
|
||||
} catch (error) {
|
||||
console.warn('[SETTINGS] Failed to create settings file, using in-memory defaults:', settingsPath, error);
|
||||
}
|
||||
return defaults;
|
||||
// Still apply env var overrides even when file doesn't exist
|
||||
return this.applyEnvOverrides(defaults);
|
||||
}
|
||||
|
||||
const settingsData = readFileSync(settingsPath, 'utf-8');
|
||||
@@ -194,10 +225,12 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
// Apply environment variable overrides (highest priority)
|
||||
return this.applyEnvOverrides(result);
|
||||
} catch (error) {
|
||||
console.warn('[SETTINGS] Failed to load settings, using defaults:', settingsPath, error);
|
||||
return this.getAllDefaults();
|
||||
// Still apply env var overrides even on error
|
||||
return this.applyEnvOverrides(this.getAllDefaults());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
|
||||
HEALTH_CHECK: 30000, // Worker health check (30s for slow systems)
|
||||
HEALTH_CHECK: 3000, // Worker health check (3s — healthy worker responds in <100ms)
|
||||
POST_SPAWN_WAIT: 5000, // Wait for daemon to start after spawn (starts in <1s on Linux)
|
||||
PORT_IN_USE_WAIT: 3000, // Wait when port occupied but health failing
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
WORKER_STARTUP_RETRIES: 300,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
|
||||
POWERSHELL_COMMAND: 10000, // PowerShell process enumeration (10s - typically completes in <1s)
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment for hook-side operations
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,8 @@ export const HOOK_EXIT_CODES = {
|
||||
FAILURE: 1,
|
||||
/** Blocking error - for SessionStart, shows stderr to user only */
|
||||
BLOCKING_ERROR: 2,
|
||||
/** Show stderr to user only, don't inject into context. Used by user-message handler (Cursor). */
|
||||
USER_MESSAGE_ONLY: 3,
|
||||
} as const;
|
||||
|
||||
export function getTimeout(baseTimeout: number): number {
|
||||
|
||||
@@ -28,6 +28,9 @@ export const DATA_DIR = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
||||
// Note: CLAUDE_CONFIG_DIR is a Claude Code setting, not claude-mem, so leave as env var
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
|
||||
// Plugin installation directory - respects CLAUDE_CONFIG_DIR for users with custom Claude locations
|
||||
export const MARKETPLACE_ROOT = join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces', 'thedotmack');
|
||||
|
||||
// Data subdirectories
|
||||
export const ARCHIVES_DIR = join(DATA_DIR, 'archives');
|
||||
export const LOGS_DIR = join(DATA_DIR, 'logs');
|
||||
@@ -38,6 +41,10 @@ export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const VECTOR_DB_DIR = join(DATA_DIR, 'vector-db');
|
||||
|
||||
// Observer sessions directory - used as cwd for SDK queries
|
||||
// Sessions here won't appear in user's `claude --resume` for their actual projects
|
||||
export const OBSERVER_SESSIONS_DIR = join(DATA_DIR, 'observer-sessions');
|
||||
|
||||
// Claude integration paths
|
||||
export const CLAUDE_SETTINGS_PATH = join(CLAUDE_CONFIG_DIR, 'settings.json');
|
||||
export const CLAUDE_COMMANDS_DIR = join(CLAUDE_CONFIG_DIR, 'commands');
|
||||
|
||||
@@ -58,7 +58,7 @@ export function extractLastMessage(
|
||||
|
||||
// If we searched the whole transcript and didn't find any message of this role
|
||||
if (!foundMatchingRole) {
|
||||
throw new Error(`No message found for role '${role}' in transcript: ${transcriptPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
+102
-46
@@ -1,15 +1,45 @@
|
||||
import path from "path";
|
||||
import { homedir } from "os";
|
||||
import { readFileSync } from "fs";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js";
|
||||
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
|
||||
import { getWorkerRestartInstructions } from "../utils/error-messages.js";
|
||||
|
||||
const MARKETPLACE_ROOT = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
import { MARKETPLACE_ROOT } from "./paths.js";
|
||||
|
||||
// Named constants for health checks
|
||||
const HEALTH_CHECK_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000)
|
||||
const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
|
||||
if (envVal) {
|
||||
const parsed = parseInt(envVal, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
|
||||
return parsed;
|
||||
}
|
||||
// Invalid env var — log once and use default
|
||||
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
|
||||
value: envVal, min: 500, max: 300000
|
||||
});
|
||||
}
|
||||
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Fetch with a timeout using Promise.race instead of AbortSignal.
|
||||
* AbortSignal.timeout() causes a libuv assertion crash in Bun on Windows,
|
||||
* so we use a racing setTimeout pattern that avoids signal cleanup entirely.
|
||||
* The orphaned fetch is harmless since the process exits shortly after.
|
||||
*/
|
||||
export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(
|
||||
() => reject(new Error(`Request timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
fetch(url, init).then(
|
||||
response => { clearTimeout(timeoutId); resolve(response); },
|
||||
err => { clearTimeout(timeoutId); reject(err); }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Cache to avoid repeated settings file reads
|
||||
let cachedPort: number | null = null;
|
||||
@@ -57,23 +87,38 @@ export function clearPortCache(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker is responsive and fully initialized by trying the readiness endpoint
|
||||
* Changed from /health to /api/readiness to ensure MCP initialization is complete
|
||||
* Check if worker HTTP server is responsive
|
||||
* Uses /api/health (liveness) instead of /api/readiness because:
|
||||
* - Hooks have 15-second timeout, but full initialization can take 5+ minutes (MCP connection)
|
||||
* - /api/health returns 200 as soon as HTTP server is up (sufficient for hook communication)
|
||||
* - /api/readiness returns 503 until full initialization completes (too slow for hooks)
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
*/
|
||||
async function isWorkerHealthy(): Promise<boolean> {
|
||||
const port = getWorkerPort();
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/health`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plugin version from package.json
|
||||
* Get the current plugin version from package.json.
|
||||
* Returns 'unknown' on ENOENT/EBUSY (shutdown race condition, fix #1042).
|
||||
*/
|
||||
function getPluginVersion(): string {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
try {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'EBUSY') {
|
||||
logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code });
|
||||
return 'unknown';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,8 +126,9 @@ function getPluginVersion(): string {
|
||||
*/
|
||||
async function getWorkerVersion(): Promise<string> {
|
||||
const port = getWorkerPort();
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/version`);
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/version`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get worker version: ${response.status}`);
|
||||
}
|
||||
@@ -93,18 +139,33 @@ async function getWorkerVersion(): Promise<string> {
|
||||
/**
|
||||
* Check if worker version matches plugin version
|
||||
* Note: Auto-restart on version mismatch is now handled in worker-service.ts start command (issue #484)
|
||||
* This function logs for informational purposes only
|
||||
* This function logs for informational purposes only.
|
||||
* Skips comparison when either version is 'unknown' (fix #1042 — avoids restart loops).
|
||||
*/
|
||||
async function checkWorkerVersion(): Promise<void> {
|
||||
const pluginVersion = getPluginVersion();
|
||||
const workerVersion = await getWorkerVersion();
|
||||
try {
|
||||
const pluginVersion = getPluginVersion();
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
// Skip version check if plugin version couldn't be read (shutdown race)
|
||||
if (pluginVersion === 'unknown') return;
|
||||
|
||||
const workerVersion = await getWorkerVersion();
|
||||
|
||||
// Skip version check if worker version is 'unknown' (avoids restart loops)
|
||||
if (workerVersion === 'unknown') return;
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Version check is informational — don't fail the hook
|
||||
logger.debug('SYSTEM', 'Version check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -112,30 +173,25 @@ async function checkWorkerVersion(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Ensure worker service is running
|
||||
* Polls until worker is ready (assumes worker-service.cjs start was called by hooks.json)
|
||||
* Quick health check - returns false if worker not healthy (doesn't block)
|
||||
* Port might be in use by another process, or worker might not be started yet
|
||||
*/
|
||||
export async function ensureWorkerRunning(): Promise<void> {
|
||||
const maxRetries = 75; // 15 seconds total
|
||||
const pollInterval = 200;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
if (await isWorkerHealthy()) {
|
||||
await checkWorkerVersion(); // logs warning on mismatch, doesn't restart
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('SYSTEM', 'Worker health check failed, will retry', {
|
||||
attempt: i + 1,
|
||||
maxRetries,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
// Quick health check (single attempt, no polling)
|
||||
try {
|
||||
if (await isWorkerHealthy()) {
|
||||
await checkWorkerVersion(); // logs warning on mismatch, doesn't restart
|
||||
return true; // Worker healthy
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollInterval));
|
||||
} catch (e) {
|
||||
// Not healthy - log for debugging
|
||||
logger.debug('SYSTEM', 'Worker health check failed', {
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(getWorkerRestartInstructions({
|
||||
port: getWorkerPort(),
|
||||
customPrefix: 'Worker did not become ready within 15 seconds.'
|
||||
}));
|
||||
// Port might be in use by something else, or worker not started
|
||||
// Return false but don't throw - let caller decide how to handle
|
||||
logger.warn('SYSTEM', 'Worker not healthy, hook will proceed gracefully');
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user