Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
"timeout": 300
}
]
Expand All @@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"timeout": 300
}
]
Expand All @@ -29,12 +29,12 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
"timeout": 60
}
]
Expand All @@ -45,7 +45,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"timeout": 60
}
]
Expand All @@ -57,7 +57,7 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"timeout": 120
}
]
Expand All @@ -68,12 +68,12 @@
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"timeout": 120
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$(ls -1dt \"$HOME/.config/claude/plugins/cache/thedotmack/claude-mem/\"*/plugin \"$HOME/.claude/plugins/cache/thedotmack/claude-mem/\"*/plugin 2>/dev/null | head -n 1)\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-complete",
"timeout": 30
}
]
Expand Down
547 changes: 271 additions & 276 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions src/services/infrastructure/ProcessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ function isBunExecutablePath(executablePath: string | undefined | null): boolean
return /(^|[\\/])bun(\.exe)?$/i.test(executablePath.trim());
}

function isNodeExecutablePath(executablePath: string | undefined | null): boolean {
if (!executablePath) return false;

return /(^|[\\/])node(\.exe)?$/i.test(executablePath.trim());
}

function lookupBinaryInPath(binaryName: string, platform: NodeJS.Platform): string | null {
const command = platform === 'win32' ? `where ${binaryName}` : `which ${binaryName}`;

Expand Down Expand Up @@ -121,6 +127,67 @@ export function resolveWorkerRuntimePath(options: RuntimeResolverOptions = {}):
return lookupInPath('bun', platform);
}

/**
* Resolve the Node.js executable used to spawn the MCP server.
*
* The worker itself runs under Bun, so `process.execPath` is often Bun on Unix.
* When the environment PATH is incomplete (launchd/cron), probe common install
* locations before falling back to PATH lookup.
*/
export function resolveNodeRuntimePath(options: RuntimeResolverOptions = {}): string | null {
const platform = options.platform ?? process.platform;
const execPath = options.execPath ?? process.execPath;
const env = options.env ?? process.env;
const homeDirectory = options.homeDirectory ?? homedir();
const pathExists = options.pathExists ?? existsSync;
const lookupInPath = options.lookupInPath ?? lookupBinaryInPath;

if (isNodeExecutablePath(execPath)) {
return execPath;
}

const overrideCandidates = [env.CLAUDE_MEM_NODE_PATH, env.NODE];
for (const candidate of overrideCandidates) {
const normalized = candidate?.trim();
if (!normalized) continue;

if (normalized.toLowerCase() == 'node') {
return normalized;
}

if (pathExists(normalized)) {
return normalized;
}
}

const candidatePaths = platform === 'win32'
? [
env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs', 'node.exe') : undefined,
env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'nodejs', 'node.exe') : undefined,
env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe') : undefined,
env.USERPROFILE ? path.join(env.USERPROFILE, '.fnm', 'aliases', 'default', 'bin', 'node.exe') : undefined,
]
: [
'/usr/local/bin/node',
'/opt/homebrew/bin/node',
'/usr/bin/node',
path.join(homeDirectory, '.fnm', 'aliases', 'default', 'bin', 'node'),
path.join(homeDirectory, '.volta', 'bin', 'node'),
path.join(homeDirectory, '.nvm', 'current', 'bin', 'node'),
];

for (const candidate of candidatePaths) {
const normalized = candidate?.trim();
if (!normalized) continue;

if (isNodeExecutablePath(normalized) && pathExists(normalized)) {
return normalized;
}
}

return lookupInPath('node', platform);
}

export interface PidInfo {
pid: number;
port: number;
Expand Down
7 changes: 6 additions & 1 deletion src/services/worker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
runOneTimeChromaMigration,
cleanStalePidFile,
isProcessAlive,
resolveNodeRuntimePath,
spawnDaemon,
createSignalHandler,
isPidFileRecent,
Expand Down Expand Up @@ -446,8 +447,12 @@ export class WorkerService {

// Connect to MCP server
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
const nodeRuntimePath = resolveNodeRuntimePath();
if (!nodeRuntimePath) {
throw new Error('Node.js executable not found for MCP startup. Set CLAUDE_MEM_NODE_PATH or install node in a standard location.');
}
const transport = new StdioClientTransport({
command: 'node',
command: nodeRuntimePath,
args: [mcpServerPath],
env: process.env
});
Expand Down
8 changes: 5 additions & 3 deletions tests/infrastructure/plugin-distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,18 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
}
});

it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
it('should include cache-first fallback while preserving marketplace as the final fallback (#1215)', () => {
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
const expectedCachePathFragment = 'plugins/cache/thedotmack/claude-mem';
const expectedMarketplaceFallback = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';

for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
for (const matcher of matchers as any[]) {
for (const hook of matcher.hooks) {
if (hook.type === 'command') {
expect(hook.command).toContain(expectedFallbackPath);
expect(hook.command).toContain(expectedCachePathFragment);
expect(hook.command).toContain(expectedMarketplaceFallback);
}
}
}
Expand Down
55 changes: 55 additions & 0 deletions tests/infrastructure/process-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isPidFileRecent,
touchPidFile,
spawnDaemon,
resolveNodeRuntimePath,
resolveWorkerRuntimePath,
runOneTimeChromaMigration,
type PidInfo
Expand Down Expand Up @@ -284,6 +285,60 @@ describe('ProcessManager', () => {
});
});


describe('resolveNodeRuntimePath', () => {
it('should reuse execPath when already running under Node', () => {
const resolved = resolveNodeRuntimePath({
platform: 'linux',
execPath: '/usr/bin/node',
env: {} as NodeJS.ProcessEnv,
pathExists: () => false,
lookupInPath: () => null
});

expect(resolved).toBe('/usr/bin/node');
});

it('should honor CLAUDE_MEM_NODE_PATH override when it points to an existing executable', () => {
const resolved = resolveNodeRuntimePath({
platform: 'darwin',
execPath: '/Users/alice/.bun/bin/bun',
env: { CLAUDE_MEM_NODE_PATH: '/custom/tools/node-wrapper' } as NodeJS.ProcessEnv,
pathExists: candidatePath => candidatePath === '/custom/tools/node-wrapper',
lookupInPath: () => null,
homeDirectory: '/Users/alice'
});

expect(resolved).toBe('/custom/tools/node-wrapper');
});

it('should ignore NODE_PATH module directories and fall back to common system paths', () => {
const resolved = resolveNodeRuntimePath({
platform: 'darwin',
execPath: '/Users/alice/.bun/bin/bun',
env: { NODE_PATH: '/Users/alice/.nvm/modules' } as NodeJS.ProcessEnv,
pathExists: candidatePath => candidatePath === '/usr/local/bin/node',
lookupInPath: () => null,
homeDirectory: '/Users/alice'
});

expect(resolved).toBe('/usr/local/bin/node');
});

it('should fall back to PATH lookup when common paths are unavailable', () => {
const resolved = resolveNodeRuntimePath({
platform: 'linux',
execPath: '/Users/alice/.bun/bin/bun',
env: {} as NodeJS.ProcessEnv,
pathExists: () => false,
lookupInPath: () => '/opt/homebrew/bin/node',
homeDirectory: '/Users/alice'
});

expect(resolved).toBe('/opt/homebrew/bin/node');
});
});

describe('isProcessAlive', () => {
it('should return true for the current process', () => {
expect(isProcessAlive(process.pid)).toBe(true);
Expand Down