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:
Alex Newman
2026-02-13 21:02:54 -05:00
257 changed files with 18546 additions and 5184 deletions
-2
View File
@@ -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 |
+272
View File
@@ -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)';
}
+40 -7
View File
@@ -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());
}
}
}
+6 -3
View File
@@ -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 {
+7
View File
@@ -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');
+1 -1
View File
@@ -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
View File
@@ -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;
}